mirror of
https://github.com/ProtonDriveApps/android-drive.git
synced 2026-05-15 09:50:34 +00:00
2.0.2
This commit is contained in:
+145
-41
@@ -1,5 +1,5 @@
|
||||
default:
|
||||
image: ${CI_REGISTRY}/android/shared/docker-android:v1.1.1
|
||||
image: ${CI_REGISTRY}/android/shared/docker-android/oci:v2.0.0
|
||||
tags:
|
||||
- shared-small
|
||||
|
||||
@@ -9,13 +9,13 @@ variables:
|
||||
# Use no compression for artifacts
|
||||
ARTIFACT_COMPRESSION_LEVEL: "fastest"
|
||||
GCLOUD_BUCKET_URL: "gs://test-lab-u7cps962nd0a4-kx5m7jhd4pki6"
|
||||
FIREBASE_RESULT_ROOT: "${CI_BUILD_REF_NAME}/${CI_COMMIT_SHORT_SHA}"
|
||||
FIREBASE_RESULT_ROOT: "${CI_COMMIT_REF_NAME}/${CI_COMMIT_SHORT_SHA}"
|
||||
ATLAS_DEPLOY_ENV: "true"
|
||||
ATLAS_DEPLOY_PREP: "true"
|
||||
|
||||
workflow:
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event" || $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH || $CI_COMMIT_REF_NAME =~ /^test/
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event" || $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
|
||||
|
||||
before_script:
|
||||
# We must keep these variables here. We can't do it inside the entrypoint, as idk how but
|
||||
@@ -30,7 +30,6 @@ before_script:
|
||||
cache:
|
||||
key: ${CI_COMMIT_REF_SLUG}
|
||||
paths:
|
||||
- .gradle
|
||||
- config/detekt/config.yml
|
||||
|
||||
stages:
|
||||
@@ -124,15 +123,19 @@ build dynamic debug:
|
||||
- echo HOST="$DYNAMIC_DOMAIN" >> private.properties
|
||||
- ./gradlew assembleDynamicDebug --max-workers=4
|
||||
- ./gradlew assembleDynamicDebugAndroidTest --max-workers=4
|
||||
- ./gradlew assembleDebugAndroidTest --max-workers=4
|
||||
- |
|
||||
./gradlew \
|
||||
:drive:files-list:assembleDebugAndroidTest \
|
||||
:drive:link:data:assembleDebugAndroidTest \
|
||||
:drive:sorting:presentation:assembleDebugAndroidTest
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event" || $CI_COMMIT_REF_NAME =~ /^test/
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
|
||||
artifacts:
|
||||
paths:
|
||||
- ./app/**/*.apk
|
||||
- ./drive/link/presentation/**/*.apk
|
||||
- ./drive/files-list/**/*.apk
|
||||
- ./drive/link/data/**/*.apk
|
||||
- ./drive/sorting/presentation/**/*.apk
|
||||
|
||||
build alpha release:
|
||||
@@ -168,14 +171,13 @@ build prod release:
|
||||
dev debug unit test:
|
||||
stage: test
|
||||
needs:
|
||||
- job: "build dynamic debug"
|
||||
- job: "build dev debug"
|
||||
- job: "prepare-gradle-build-scan"
|
||||
optional: true
|
||||
tags:
|
||||
- xlarge-k8s
|
||||
script:
|
||||
- ./gradlew testDevDebugUnitTest testDebugUnitTest
|
||||
allow_failure: true
|
||||
artifacts:
|
||||
expire_in: 1 week
|
||||
reports:
|
||||
@@ -192,11 +194,6 @@ upload to firebase:
|
||||
- gcloud auth activate-service-account --key-file app/service_account.json --quiet
|
||||
- export APK_NAME=${ARCHIVES_BASE_NAME}-${PRODUCT_FLAVOR}-debug.apk
|
||||
- gsutil cp "app/build/outputs/apk/$PRODUCT_FLAVOR/debug/${APK_NAME}" "$GCLOUD_BUCKET_URL/$FIREBASE_RESULT_ROOT/${APK_NAME}"
|
||||
rules:
|
||||
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH || $CI_COMMIT_REF_NAME =~ /^test/
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
when: manual
|
||||
allow_failure: true
|
||||
cache: []
|
||||
|
||||
# Integration tests
|
||||
@@ -206,13 +203,17 @@ upload to firebase:
|
||||
- job: "upload to firebase"
|
||||
- job: "prepare-build"
|
||||
- job: "build dynamic debug"
|
||||
- job: "deploy:review"
|
||||
stage: test
|
||||
tags:
|
||||
- shared-medium
|
||||
variables:
|
||||
RESULTS_DIR: "$FIREBASE_RESULT_ROOT/$CI_JOB_NAME"
|
||||
RESULTS_DIR: "${FIREBASE_RESULT_ROOT}/${CI_JOB_NAME}"
|
||||
PRODUCT_FLAVOR: "dynamic"
|
||||
FIREBASE_LOG_FILE: "${CI_JOB_NAME}.firebase_log"
|
||||
DEVICE_CONFIG: "quickTest"
|
||||
TARGET_APP: "${GCLOUD_BUCKET_URL}/${FIREBASE_RESULT_ROOT}/${ARCHIVES_BASE_NAME}-${PRODUCT_FLAVOR}-debug.apk"
|
||||
TEST_RUNNER_CLASS: "androidx.test.runner.AndroidJUnitRunner"
|
||||
script:
|
||||
- echo ${ARCHIVES_BASE_NAME}
|
||||
- if [ "$TEST_ARCHIVES_BASE_NAME" == "" ]; then export TEST_ARCHIVES_BASE_NAME=$ARCHIVES_BASE_NAME; fi
|
||||
@@ -222,21 +223,25 @@ upload to firebase:
|
||||
- export RANDOM_COVERAGE_NAME=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 13 ; echo '')
|
||||
- gcloud config set project $CLOUD_PROJECT_ID --quiet
|
||||
- gcloud auth activate-service-account --key-file app/service_account.json --quiet
|
||||
- gcloud firebase test android run firebase-device-config.yml:${TESTS_TYPE}
|
||||
- gcloud firebase test android run firebase-device-config.yml:${DEVICE_CONFIG}
|
||||
--app "$TARGET_APP"
|
||||
--test "$TEST_APP"
|
||||
--num-flaky-test-attempts=1
|
||||
--environment-variables coverage=true,coverageFile="/sdcard/$RANDOM_COVERAGE_NAME$COVERAGE_FILE_NAME.ec"
|
||||
--directories-to-pull /sdcard
|
||||
--environment-variables coverage=true,coverageFile="/sdcard/Download/$RANDOM_COVERAGE_NAME$COVERAGE_FILE_NAME.ec",clearPackageData=true
|
||||
--directories-to-pull /sdcard/Download,/sdcard/Pictures/Screenshots
|
||||
--results-dir="$RESULTS_DIR"
|
||||
- coverageFile=`gsutil ls $GCLOUD_BUCKET_URL/**/$RANDOM_COVERAGE_NAME$COVERAGE_FILE_NAME.ec | tail -1`
|
||||
- gsutil cp $coverageFile $TEST_APP_LOCATION | true
|
||||
--test-targets="${TEST_TARGETS}"
|
||||
--test-runner-class="${TEST_RUNNER_CLASS}"
|
||||
--use-orchestrator 2>&1 | tee $FIREBASE_LOG_FILE
|
||||
after_script:
|
||||
# Prepare and pull artifacts
|
||||
- export $(cat deploy.env)
|
||||
- mkdir firebase_artifacts
|
||||
- gsutil ls "$GCLOUD_BUCKET_URL/$RESULTS_DIR" | grep '/$' | gsutil -m cp -r -I ./firebase_artifacts
|
||||
- export $(cat deploy.env)
|
||||
- export ATLAS_LINK_APP=drive
|
||||
- echo "" >> $FIREBASE_LOG_FILE
|
||||
- echo GITLAB_JOB_URL=$CI_JOB_URL >> $FIREBASE_LOG_FILE
|
||||
- echo GITLAB_JOB_NAME=$CI_JOB_NAME >> $FIREBASE_LOG_FILE
|
||||
# Attach screenshots and improve readability for Gitlab test report
|
||||
- process_firebase_report.py
|
||||
--path firebase_artifacts
|
||||
@@ -246,58 +251,140 @@ upload to firebase:
|
||||
- merge_reports.py
|
||||
--path firebase_artifacts
|
||||
--output ${CI_JOB_NAME}_report.xml
|
||||
# Send private slack message with test results
|
||||
- test_reporter.py
|
||||
--path .
|
||||
--platform android
|
||||
--job-name $CI_JOB_NAME
|
||||
--slack-channel drive-android-ci-reports
|
||||
--push-metrics
|
||||
rules:
|
||||
# allow failure so non-run tests don't block pipeline
|
||||
- allow_failure: true
|
||||
artifacts:
|
||||
expire_in: 1 week
|
||||
paths:
|
||||
- ./**/*.ec
|
||||
- firebase_artifacts
|
||||
- ./*.firebase_log
|
||||
- firebase_output.txt
|
||||
- ${CI_JOB_NAME}_report.xml
|
||||
reports:
|
||||
junit: ${CI_JOB_NAME}_report.xml
|
||||
when: always
|
||||
cache: []
|
||||
|
||||
|
||||
drive-files-list-firebase-tests:
|
||||
extends: .tests_preparation_script
|
||||
variables:
|
||||
TESTS_TYPE: quickTest
|
||||
TEST_APP_LOCATION: "drive/files-list/build/outputs/apk/androidTest/debug/"
|
||||
TEST_APP_TYPE: "debug-androidTest.apk"
|
||||
TEST_ARCHIVES_BASE_NAME: "files-list"
|
||||
COVERAGE_FILE_NAME: "driveFilesListQuickCoverageMobile"
|
||||
TEST_TARGETS: "package me.proton.core.drive.files"
|
||||
|
||||
drive-link-data-firebase-tests:
|
||||
extends: .tests_preparation_script
|
||||
variables:
|
||||
TEST_APP_LOCATION: "drive/link/data/build/outputs/apk/androidTest/debug/"
|
||||
TEST_APP_TYPE: "debug-androidTest.apk"
|
||||
TEST_ARCHIVES_BASE_NAME: "data"
|
||||
COVERAGE_FILE_NAME: "driveLinkDataQuickCoverageMobile"
|
||||
TEST_TARGETS: "package me.proton.core.drive.link.data"
|
||||
|
||||
drive-sorting-presentation-firebase-tests:
|
||||
extends: .tests_preparation_script
|
||||
variables:
|
||||
TESTS_TYPE: quickTest
|
||||
TEST_APP_LOCATION: "drive/sorting/presentation/build/outputs/apk/androidTest/debug/"
|
||||
TEST_APP_TYPE: "debug-androidTest.apk"
|
||||
TEST_ARCHIVES_BASE_NAME: "presentation"
|
||||
COVERAGE_FILE_NAME: "driveSortingPresentationQuickCoverageMobile"
|
||||
TEST_TARGETS: "package me.proton.core.drive.sorting.presentation"
|
||||
|
||||
app-firebase-tests:
|
||||
.app-firebase-tests:
|
||||
extends: .tests_preparation_script
|
||||
variables:
|
||||
TESTS_TYPE: quickTest
|
||||
TEST_APP_LOCATION: "app/build/outputs/apk/androidTest/dynamic/debug/"
|
||||
TEST_APP_TYPE: "${PRODUCT_FLAVOR}-debug-androidTest.apk"
|
||||
TEST_ARCHIVES_BASE_NAME: ""
|
||||
COVERAGE_FILE_NAME: "appQuickCoverageMobile"
|
||||
COVERAGE_FILE_NAME: "appQuickCoverageMobile-${CI_JOB_NAME}"
|
||||
TEST_RUNNER_CLASS: "me.proton.android.drive.ui.HiltTestRunner"
|
||||
rules:
|
||||
# Allow failure always for e2e tests for now
|
||||
# Change to false, once stability on dynamic atlas env is verified
|
||||
- allow_failure: true
|
||||
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
|
||||
allow_failure: true
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
when: manual
|
||||
allow_failure: true
|
||||
|
||||
test:firebase:e2e:smoke:
|
||||
extends: .app-firebase-tests
|
||||
rules:
|
||||
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
|
||||
when: manual
|
||||
allow_failure: true
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
allow_failure: true
|
||||
variables:
|
||||
TEST_TARGETS: "annotation me.proton.android.drive.ui.test.SmokeTest"
|
||||
|
||||
test:firebase:e2e:account:
|
||||
extends: .app-firebase-tests
|
||||
variables:
|
||||
TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.account"
|
||||
|
||||
test:firebase:e2e:creatingFolder:
|
||||
extends: .app-firebase-tests
|
||||
variables:
|
||||
TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.creatingFolder"
|
||||
|
||||
test:firebase:e2e:details:
|
||||
extends: .app-firebase-tests
|
||||
variables:
|
||||
TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.details"
|
||||
|
||||
test:firebase:e2e:move:
|
||||
extends: .app-firebase-tests
|
||||
variables:
|
||||
TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.move"
|
||||
DEVICE_CONFIG: "quickTest-2"
|
||||
|
||||
test:firebase:e2e:offline:
|
||||
extends: .app-firebase-tests
|
||||
variables:
|
||||
TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.offline"
|
||||
|
||||
test:firebase:e2e:rename:
|
||||
extends: .app-firebase-tests
|
||||
variables:
|
||||
TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.rename"
|
||||
|
||||
test:firebase:e2e:settings:
|
||||
extends: .app-firebase-tests
|
||||
variables:
|
||||
TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.settings"
|
||||
DEVICE_CONFIG: "quickTest-3"
|
||||
|
||||
test:firebase:e2e:share:
|
||||
extends: .app-firebase-tests
|
||||
variables:
|
||||
TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.share"
|
||||
DEVICE_CONFIG: "quickTest-2"
|
||||
|
||||
test:firebase:e2e:subscription:
|
||||
extends: .app-firebase-tests
|
||||
variables:
|
||||
TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.subscription"
|
||||
|
||||
test:firebase:e2e:trash:
|
||||
extends: .app-firebase-tests
|
||||
variables:
|
||||
TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.trash"
|
||||
|
||||
test:firebase:e2e:upload:
|
||||
extends: .app-firebase-tests
|
||||
variables:
|
||||
TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.upload"
|
||||
|
||||
test:firebase:e2e:photos:
|
||||
extends: .app-firebase-tests
|
||||
variables:
|
||||
TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.photos"
|
||||
|
||||
test:firebase:e2e:navigate:
|
||||
extends: .app-firebase-tests
|
||||
variables:
|
||||
TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.navigate"
|
||||
DEVICE_CONFIG: "quickTest-3"
|
||||
|
||||
coverage report:
|
||||
stage: report
|
||||
@@ -316,11 +403,12 @@ coverage report:
|
||||
coverage_format: cobertura
|
||||
path: ./**/build/reports/cobertura-coverage.xml
|
||||
rules:
|
||||
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH || $CI_COMMIT_REF_NAME =~ /^test/
|
||||
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
|
||||
when: on_success
|
||||
- when: manual
|
||||
- allow_failure: true
|
||||
|
||||
|
||||
testmo-upload:
|
||||
stage: report
|
||||
allow_failure: true
|
||||
@@ -339,6 +427,19 @@ testmo-upload:
|
||||
RESULT_FOLDER: "./*.xml"
|
||||
cache: []
|
||||
|
||||
report:slack:
|
||||
image: $CI_REGISTRY/tpe/test-scripts
|
||||
stage: report
|
||||
when: always
|
||||
allow_failure: true
|
||||
tags:
|
||||
- shared-small
|
||||
script:
|
||||
- firebase_reporter.py
|
||||
--path .
|
||||
--slack-channel drive-android-ci-reports
|
||||
cache: []
|
||||
|
||||
publish to firebase app distribution:
|
||||
stage: publish
|
||||
tags:
|
||||
@@ -358,6 +459,9 @@ publish to firebase app distribution:
|
||||
--groups "qa-team, dev-team, management-team"
|
||||
rules:
|
||||
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
when: manual
|
||||
allow_failure: true
|
||||
|
||||
startReview:
|
||||
needs:
|
||||
|
||||
@@ -20,4 +20,8 @@ plugins {
|
||||
id("com.android.library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "me.proton.android.drive.lock"
|
||||
}
|
||||
|
||||
driveModule(includeSubmodules = true)
|
||||
|
||||
@@ -20,6 +20,10 @@ plugins {
|
||||
id("com.android.library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "me.proton.android.drive.lock.data"
|
||||
}
|
||||
|
||||
driveModule(
|
||||
hilt = true,
|
||||
room = true,
|
||||
@@ -29,5 +33,6 @@ driveModule(
|
||||
) {
|
||||
api(project(":app-lock:domain"))
|
||||
api(project(":drive:base:data"))
|
||||
api(project(":drive:base:presentation")) // failing to merge values without it.
|
||||
api(libs.androidx.biometric)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,10 @@ plugins {
|
||||
id("com.android.library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "me.proton.android.drive.lock.domain"
|
||||
}
|
||||
|
||||
driveModule(
|
||||
hilt = true,
|
||||
serialization = true,
|
||||
|
||||
@@ -20,12 +20,17 @@ plugins {
|
||||
id("com.android.library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "me.proton.android.drive.lock.presentation"
|
||||
}
|
||||
|
||||
driveModule(
|
||||
hilt = true,
|
||||
compose = true,
|
||||
i18n = true,
|
||||
) {
|
||||
api(project(":app-lock:domain"))
|
||||
implementation(project(":drive:base:data"))
|
||||
implementation(project(":drive:base:presentation"))
|
||||
implementation(libs.accompanist.drawablepainter)
|
||||
}
|
||||
|
||||
+3
-3
@@ -25,13 +25,13 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.launch
|
||||
import me.proton.android.drive.lock.domain.exception.LockException
|
||||
import me.proton.android.drive.lock.domain.usecase.UnlockApp
|
||||
import me.proton.android.drive.lock.presentation.extension.getDefaultMessage
|
||||
import me.proton.android.drive.lock.presentation.viewevent.UnlockViewEvent
|
||||
import me.proton.core.domain.entity.UserId
|
||||
import me.proton.core.drive.base.domain.usecase.SignOut
|
||||
import me.proton.android.drive.lock.presentation.extension.getDefaultMessage
|
||||
import me.proton.core.drive.base.data.extension.getDefaultMessage
|
||||
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
|
||||
import me.proton.core.drive.base.domain.usecase.BroadcastMessages
|
||||
import me.proton.core.drive.base.presentation.extension.getDefaultMessage
|
||||
import me.proton.core.drive.base.domain.usecase.SignOut
|
||||
import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ plugins {
|
||||
id("com.android.library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "me.proton.android.drive.settings"
|
||||
}
|
||||
|
||||
driveModule(
|
||||
hilt = true,
|
||||
room = true,
|
||||
|
||||
+26
-6
@@ -22,6 +22,8 @@ plugins {
|
||||
id("com.android.application")
|
||||
id("dagger.hilt.android.plugin")
|
||||
id("kotlin-parcelize")
|
||||
kotlin("android")
|
||||
kotlin("kapt")
|
||||
}
|
||||
|
||||
base {
|
||||
@@ -52,6 +54,7 @@ driveModule(
|
||||
implementation(project(":app-ui-settings"))
|
||||
implementation(project(":drive"))
|
||||
implementation(project(":verifier"))
|
||||
implementation(project(":photos"))
|
||||
|
||||
implementation(libs.androidx.activity.ktx)
|
||||
implementation(libs.androidx.compose.foundationLayout)
|
||||
@@ -66,13 +69,21 @@ driveModule(
|
||||
implementation(libs.plumber)
|
||||
implementation(libs.sentry)
|
||||
implementation(libs.timber)
|
||||
implementation(libs.treessence)
|
||||
|
||||
debugImplementation(libs.treessence)
|
||||
androidTestImplementation(libs.dagger.hilt.android.testing)
|
||||
kapt(libs.dagger.hilt.android.compiler)
|
||||
kaptAndroidTest(libs.dagger.hilt.android.compiler)
|
||||
|
||||
androidTestUtil(libs.androidx.test.orchestrator)
|
||||
androidTestUtil(libs.androidx.test.services)
|
||||
|
||||
testImplementation(project(":drive:db-test"))
|
||||
androidTestImplementation(libs.androidx.navigation.compose)
|
||||
androidTestImplementation(libs.androidx.test.espresso.contrib)
|
||||
androidTestImplementation(libs.bundles.core.test)
|
||||
androidTestImplementation(libs.fusion)
|
||||
androidTestUtil(libs.androidx.test.orchestrator)
|
||||
|
||||
coreLibraryDesugaring(libs.desugar.jdk.libs)
|
||||
}
|
||||
@@ -91,6 +102,7 @@ val lastAlpha = tags.countSubstrings("${Config.versionName}-alpha")
|
||||
val lastBeta = tags.countSubstrings("${Config.versionName}-beta")
|
||||
|
||||
android {
|
||||
namespace = "me.proton.android.drive"
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = file(privateProperties.getProperty("SIGN_KEY_STORE_FILE_PATH") ?: "protonkey.jks")
|
||||
@@ -99,9 +111,13 @@ android {
|
||||
keyPassword = privateProperties.getProperty("SIGN_KEY_ALIAS_PASSWORD")
|
||||
}
|
||||
}
|
||||
testOptions {
|
||||
execution = "ANDROIDX_TEST_ORCHESTRATOR"
|
||||
}
|
||||
|
||||
val gitHash = "git rev-parse --short HEAD".runCommand(workingDir = rootDir)
|
||||
defaultConfig {
|
||||
|
||||
buildConfigField("String", "HOST", "\"proton.me\"")
|
||||
buildConfigField("String", "BASE_URL", "\"https://drive-api.proton.me/\"")
|
||||
buildConfigField("String", "APP_VERSION_HEADER", "\"android-drive@$versionName\"")
|
||||
@@ -111,8 +127,11 @@ android {
|
||||
buildConfigField("String", "FLAVOR_BETA", "\"beta\"")
|
||||
buildConfigField("String", "FLAVOR_PRODUCTION", "\"prod\"")
|
||||
buildConfigField("String", "SENTRY_DSN", "\"https://28f8df131f7a4ca4940e86972ba5038f@drive-api.proton.me/core/v4/reports/sentry/11\"")
|
||||
buildConfigField("String", "ACCOUNT_SENTRY_DSN", "\"${System.getenv("ACCOUNT_SENTRY_DSN").orEmpty()}\"")
|
||||
buildConfigField("String", "GIT_HASH", "\"$gitHash\"")
|
||||
buildConfigField("String", "PROXY_TOKEN", "\"${privateProperties.getProperty("PROXY_TOKEN")}\"")
|
||||
testInstrumentationRunner = "me.proton.android.drive.ui.HiltTestRunner"
|
||||
|
||||
}
|
||||
flavorDimensions.add("default")
|
||||
productFlavors {
|
||||
@@ -140,6 +159,8 @@ android {
|
||||
|
||||
buildConfigField("String", "HOST", "\"$host\"")
|
||||
buildConfigField("String", "BASE_URL", "\"https://drive.$host/api/\"")
|
||||
|
||||
testInstrumentationRunnerArguments["clearPackageData"] = "true"
|
||||
}
|
||||
create("alpha") {
|
||||
versionNameSuffix = "-alpha%02d".format(lastAlpha + 1)
|
||||
@@ -168,6 +189,10 @@ android {
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
|
||||
testOptions {
|
||||
execution = "ANDROIDX_TEST_ORCHESTRATOR"
|
||||
}
|
||||
}
|
||||
|
||||
tasks.create("publishGeneratedReleaseNotes") {
|
||||
@@ -191,9 +216,4 @@ tasks.create("printGeneratedChangelog") {
|
||||
}
|
||||
}
|
||||
|
||||
configurations.all {
|
||||
// androidx.test includes junit 4.12 so this will force that entire project uses same junit version
|
||||
resolutionStrategy.force(libs.junit)
|
||||
}
|
||||
|
||||
configureJacoco(flavor = "dev")
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 2,
|
||||
"identityHash": "8c1721b938aa5c0f91e616647ae1c77e",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "AppLockEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`key`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "key",
|
||||
"columnName": "key",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"key"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "LockEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`passphrase` TEXT NOT NULL, `key` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`passphrase`), FOREIGN KEY(`key`) REFERENCES `AppLockEntity`(`key`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "appKeyPassphrase",
|
||||
"columnName": "passphrase",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "appKey",
|
||||
"columnName": "key",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"passphrase"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_LockEntity_key",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"key"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_LockEntity_key` ON `${TABLE_NAME}` (`key`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "AppLockEntity",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"key"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"key"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "AutoLockDurationEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `duration` INTEGER NOT NULL, PRIMARY KEY(`key`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "key",
|
||||
"columnName": "key",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "durationInSeconds",
|
||||
"columnName": "duration",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"key"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "EnableAppLockEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `last_access_time` INTEGER NOT NULL, PRIMARY KEY(`key`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "key",
|
||||
"columnName": "key",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "last_access_time",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"key"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "ClientUidEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, PRIMARY KEY(`key`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "key",
|
||||
"columnName": "key",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"key"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8c1721b938aa5c0f91e616647ae1c77e')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ class GetFileLoggerTree @Inject constructor(
|
||||
.withFileName(debugLog.name)
|
||||
.withDir(requireNotNull(debugLog.parentFile))
|
||||
.withSizeLimit(25.MiB.value.toInt())
|
||||
.withFileLimit(1)
|
||||
.withFileLimit(10)
|
||||
.withMinPriority(Log.DEBUG)
|
||||
.appendToFile(true)
|
||||
.withFilter(
|
||||
|
||||
@@ -18,8 +18,7 @@
|
||||
-->
|
||||
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="me.proton.android.drive">
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
@@ -104,17 +103,24 @@
|
||||
tools:node="remove"/>
|
||||
<meta-data
|
||||
android:name="me.proton.android.drive.initializer.DocumentsProviderInitializer"
|
||||
android:value="androidx.startup" />
|
||||
android:value="androidx.startup"
|
||||
tools:node="remove" /><!-- Initialized by MainInitializer -->
|
||||
<meta-data
|
||||
android:name="me.proton.android.drive.initializer.AutoLockInitializer"
|
||||
android:value="androidx.startup"
|
||||
tools:node="remove"/><!-- Initialized by MainInitializer -->
|
||||
<meta-data
|
||||
android:name="me.proton.android.drive.initializer.NotificationChannelInitializer"
|
||||
android:value="androidx.startup" />
|
||||
android:value="androidx.startup"
|
||||
tools:node="remove" /><!-- Initialized by MainInitializer -->
|
||||
<meta-data
|
||||
android:name="me.proton.android.drive.initializer.SelectionInitializer"
|
||||
android:value="androidx.startup"
|
||||
tools:node="remove" /><!-- Initialized by MainInitializer -->
|
||||
<meta-data
|
||||
android:name="me.proton.android.drive.initializer.SentryInitializer"
|
||||
android:value="androidx.startup" />
|
||||
android:value="androidx.startup"
|
||||
tools:node="remove" /><!-- Initialized by MainInitializer -->
|
||||
<meta-data
|
||||
android:name="androidx.work.WorkManagerInitializer"
|
||||
android:value="androidx.startup"
|
||||
@@ -133,10 +139,12 @@
|
||||
tools:node="remove" /><!-- Initialized by MainInitializer -->
|
||||
<meta-data
|
||||
android:name="me.proton.android.drive.initializer.AccountStateHandlerInitializer"
|
||||
android:value="androidx.startup" />
|
||||
android:value="androidx.startup"
|
||||
tools:node="remove" /><!-- Initialized by MainInitializer -->
|
||||
<meta-data
|
||||
android:name="me.proton.android.drive.initializer.AccountRemovedHandlerInitializer"
|
||||
android:value="androidx.startup" />
|
||||
android:value="androidx.startup"
|
||||
tools:node="remove" /><!-- Initialized by MainInitializer -->
|
||||
<meta-data
|
||||
android:name="me.proton.android.drive.initializer.EventManagerInitializer"
|
||||
android:value="androidx.startup"
|
||||
@@ -151,13 +159,16 @@
|
||||
tools:node="remove" /><!-- Initialized by MainInitializer -->
|
||||
<meta-data
|
||||
android:name="me.proton.android.drive.initializer.LoggerInitializer"
|
||||
android:value="androidx.startup" />
|
||||
android:value="androidx.startup"
|
||||
tools:node="remove" /><!-- Initialized by MainInitializer -->
|
||||
<meta-data
|
||||
android:name="me.proton.android.drive.initializer.UncaughtExceptionHandlerInitializer"
|
||||
android:value="androidx.startup" />
|
||||
android:value="androidx.startup"
|
||||
tools:node="remove" /><!-- Initialized by MainInitializer -->
|
||||
<meta-data
|
||||
android:name="me.proton.core.crypto.validator.presentation.init.CryptoValidatorInitializer"
|
||||
android:value="androidx.startup" />
|
||||
android:value="androidx.startup"
|
||||
tools:node="remove" /><!-- Initialized by MainInitializer -->
|
||||
</provider>
|
||||
|
||||
<provider
|
||||
|
||||
@@ -19,8 +19,10 @@
|
||||
package me.proton.android.drive.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.AutoMigration
|
||||
import androidx.room.Database
|
||||
import androidx.room.migration.Migration
|
||||
import me.proton.android.drive.db.entity.ClientUidEntity
|
||||
import me.proton.android.drive.lock.data.db.AppLockDatabase
|
||||
import me.proton.android.drive.lock.data.db.entity.AppLockEntity
|
||||
import me.proton.android.drive.lock.data.db.entity.AutoLockDurationEntity
|
||||
@@ -35,13 +37,19 @@ import me.proton.core.data.room.db.BaseDatabase
|
||||
LockEntity::class,
|
||||
AutoLockDurationEntity::class,
|
||||
EnableAppLockEntity::class,
|
||||
ClientUidEntity::class,
|
||||
],
|
||||
version = AppDatabase.VERSION,
|
||||
autoMigrations = [
|
||||
AutoMigration(from = 1, to = 2)
|
||||
]
|
||||
)
|
||||
abstract class AppDatabase : BaseDatabase(), AppLockDatabase {
|
||||
abstract class AppDatabase : BaseDatabase(),
|
||||
AppLockDatabase,
|
||||
ClientUidDatabase {
|
||||
|
||||
companion object {
|
||||
const val VERSION = 1
|
||||
const val VERSION = 2
|
||||
private val migrations = listOf<Migration>()
|
||||
|
||||
fun buildDatabase(context: Context): AppDatabase =
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.db
|
||||
|
||||
import me.proton.android.drive.db.dao.ClientUidDao
|
||||
import me.proton.core.data.room.db.Database
|
||||
|
||||
interface ClientUidDatabase : Database {
|
||||
val clientUidDao: ClientUidDao
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.db.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import me.proton.android.drive.db.entity.ClientUidEntity
|
||||
import me.proton.core.data.room.db.BaseDao
|
||||
|
||||
@Dao
|
||||
abstract class ClientUidDao : BaseDao<ClientUidEntity>() {
|
||||
|
||||
@Query("SELECT * FROM ClientUidEntity")
|
||||
abstract suspend fun get(): ClientUidEntity?
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.db.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import me.proton.core.drive.base.data.db.Column.KEY
|
||||
import me.proton.core.drive.base.domain.entity.ClientUid
|
||||
|
||||
@Entity(
|
||||
primaryKeys = [KEY],
|
||||
)
|
||||
data class ClientUidEntity(
|
||||
@ColumnInfo(name = KEY)
|
||||
val key: ClientUid,
|
||||
)
|
||||
@@ -26,6 +26,7 @@ import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import me.proton.android.drive.db.AppDatabase
|
||||
import me.proton.android.drive.db.ClientUidDatabase
|
||||
import me.proton.android.drive.lock.data.db.AppLockDatabase
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -45,4 +46,7 @@ abstract class AppDatabaseBindsModule {
|
||||
|
||||
@Binds
|
||||
abstract fun provideAppLockDatabase(db: AppDatabase): AppLockDatabase
|
||||
|
||||
@Binds
|
||||
abstract fun provideClientUidDatabase(db: AppDatabase): ClientUidDatabase
|
||||
}
|
||||
|
||||
@@ -29,20 +29,29 @@ import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.ElementsIntoSet
|
||||
import me.proton.android.drive.BuildConfig
|
||||
import me.proton.android.drive.log.DriveLogger
|
||||
import me.proton.android.drive.notification.AppNotificationBuilderProvider
|
||||
import me.proton.android.drive.notification.AppNotificationEventHandler
|
||||
import me.proton.android.drive.notification.NotificationEventHandler
|
||||
import me.proton.android.drive.photos.domain.handler.PhotosEventHandler
|
||||
import me.proton.android.drive.provider.BuildConfigurationProvider
|
||||
import me.proton.android.drive.repository.BridgeFindDuplicatesRepository
|
||||
import me.proton.android.drive.repository.ClientUidRepositoryImpl
|
||||
import me.proton.android.drive.settings.DebugSettings
|
||||
import me.proton.android.drive.stats.StatsEventHandler
|
||||
import me.proton.android.drive.telemetry.TelemetryEventHandler
|
||||
import me.proton.android.drive.usecase.GetDocumentsProviderRootsImpl
|
||||
import me.proton.android.drive.usecase.notification.UploadNotificationEventWorkerNotifier
|
||||
import me.proton.core.account.domain.entity.AccountType
|
||||
import me.proton.core.domain.entity.AppStore
|
||||
import me.proton.core.domain.entity.Product
|
||||
import me.proton.core.drive.backup.domain.repository.FindDuplicatesRepository
|
||||
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
|
||||
import me.proton.core.drive.base.domain.repository.ClientUidRepository
|
||||
import me.proton.core.drive.documentsprovider.domain.usecase.GetDocumentsProviderRoots
|
||||
import me.proton.core.drive.notification.data.provider.NotificationBuilderProvider
|
||||
import me.proton.core.drive.notification.domain.handler.NotificationEventHandler
|
||||
import me.proton.core.drive.upload.data.worker.UploadEventWorker
|
||||
import me.proton.drive.android.settings.data.datastore.AppUiSettingsDataStore
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -78,7 +87,11 @@ object ApplicationModule {
|
||||
debugSettings: DebugSettings,
|
||||
buildConfigurationProvider: BuildConfigurationProvider,
|
||||
): ConfigurationProvider =
|
||||
if (BuildConfig.DEBUG) debugSettings else buildConfigurationProvider
|
||||
if (BuildConfig.DEBUG || BuildConfig.FLAVOR == BuildConfig.FLAVOR_ALPHA) {
|
||||
debugSettings
|
||||
} else {
|
||||
buildConfigurationProvider
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@@ -113,22 +126,40 @@ object ApplicationModule {
|
||||
@Singleton
|
||||
fun provideActivityManager(@ApplicationContext context: Context): ActivityManager =
|
||||
context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@ElementsIntoSet
|
||||
fun provideEventHandlers(
|
||||
notification: NotificationEventHandler,
|
||||
stats: StatsEventHandler,
|
||||
photos: PhotosEventHandler,
|
||||
telemetry: TelemetryEventHandler,
|
||||
) = setOf(notification, telemetry, photos, stats)
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class ApplicationBindsModule {
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindsNotificationEventHandler(impl: AppNotificationEventHandler): NotificationEventHandler
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindsNotificationBuilderProvider(
|
||||
impl: AppNotificationBuilderProvider
|
||||
impl: AppNotificationBuilderProvider,
|
||||
): NotificationBuilderProvider
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindsGetDocumentsProviderRootsImpl(impl: GetDocumentsProviderRootsImpl): GetDocumentsProviderRoots
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindsClientUidRepositoryImpl(impl: ClientUidRepositoryImpl): ClientUidRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindsBridgeFindDuplicatesRepository(impl: BridgeFindDuplicatesRepository): FindDuplicatesRepository
|
||||
|
||||
@Binds
|
||||
abstract fun bindsUploadNotificationEventWorkerNotifier(impl: UploadNotificationEventWorkerNotifier): UploadEventWorker.Notifier
|
||||
}
|
||||
|
||||
@@ -26,15 +26,19 @@ import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import me.proton.android.drive.db.DriveDatabase
|
||||
import me.proton.android.drive.photos.data.db.MediaStoreVersionDatabase
|
||||
import me.proton.core.account.data.db.AccountDatabase
|
||||
import me.proton.core.challenge.data.db.ChallengeDatabase
|
||||
import me.proton.core.drive.backup.data.db.BackupDatabase
|
||||
import me.proton.core.drive.drivelink.data.db.DriveLinkDatabase
|
||||
import me.proton.core.drive.drivelink.download.data.db.DriveLinkDownloadDatabase
|
||||
import me.proton.core.drive.drivelink.offline.data.db.DriveLinkOfflineDatabase
|
||||
import me.proton.core.drive.drivelink.paged.data.db.DriveLinkPagedDatabase
|
||||
import me.proton.core.drive.drivelink.photo.data.db.DriveLinkPhotoDatabase
|
||||
import me.proton.core.drive.drivelink.selection.data.db.DriveLinkSelectionDatabase
|
||||
import me.proton.core.drive.drivelink.shared.data.db.DriveLinkSharedDatabase
|
||||
import me.proton.core.drive.drivelink.trash.data.db.DriveLinkTrashDatabase
|
||||
import me.proton.core.drive.feature.flag.data.db.DriveFeatureFlagDatabase
|
||||
import me.proton.core.drive.folder.data.db.FolderDatabase
|
||||
import me.proton.core.drive.link.data.db.LinkDatabase
|
||||
import me.proton.core.drive.link.selection.data.db.LinkSelectionDatabase
|
||||
@@ -45,9 +49,12 @@ import me.proton.core.drive.linktrash.data.db.LinkTrashDatabase
|
||||
import me.proton.core.drive.linkupload.data.db.LinkUploadDatabase
|
||||
import me.proton.core.drive.messagequeue.data.storage.db.MessageQueueDatabase
|
||||
import me.proton.core.drive.notification.data.db.NotificationDatabase
|
||||
import me.proton.core.drive.photo.data.db.PhotoDatabase
|
||||
import me.proton.core.drive.share.data.db.ShareDatabase
|
||||
import me.proton.core.drive.shareurl.base.data.db.ShareUrlDatabase
|
||||
import me.proton.core.drive.sorting.data.db.SortingDatabase
|
||||
import me.proton.core.drive.stats.data.db.StatsDatabase
|
||||
import me.proton.core.drive.user.data.db.QuotaDatabase
|
||||
import me.proton.core.drive.volume.data.db.VolumeDatabase
|
||||
import me.proton.core.drive.worker.data.db.WorkerDatabase
|
||||
import me.proton.core.eventmanager.data.db.EventMetadataDatabase
|
||||
@@ -58,12 +65,15 @@ import me.proton.core.key.data.db.PublicAddressDatabase
|
||||
import me.proton.core.keytransparency.data.local.KeyTransparencyDatabase
|
||||
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.usersettings.data.db.OrganizationDatabase
|
||||
import me.proton.core.usersettings.data.db.UserSettingsDatabase
|
||||
import me.proton.drive.android.settings.data.db.AppUiSettingsDatabase
|
||||
import javax.inject.Singleton
|
||||
import me.proton.core.notification.data.local.db.NotificationDatabase as CoreNotificationDatabase
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
@@ -75,6 +85,7 @@ object DriveDatabaseModule {
|
||||
}
|
||||
|
||||
@Module
|
||||
@Suppress("unused")
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class DriveDatabaseBindsModule {
|
||||
|
||||
@@ -180,6 +191,12 @@ abstract class DriveDatabaseBindsModule {
|
||||
@Binds
|
||||
abstract fun providePaymentDatabase(db: DriveDatabase): PaymentDatabase
|
||||
|
||||
@Binds
|
||||
abstract fun provideBackupDatabase(db: DriveDatabase): BackupDatabase
|
||||
|
||||
@Binds
|
||||
abstract fun provideQuotaDatabase(db: DriveDatabase): QuotaDatabase
|
||||
|
||||
@Binds
|
||||
abstract fun provideObservabilityDatabase(db: DriveDatabase): ObservabilityDatabase
|
||||
|
||||
@@ -188,4 +205,28 @@ abstract class DriveDatabaseBindsModule {
|
||||
|
||||
@Binds
|
||||
abstract fun provideWorkerDatabase(db: DriveDatabase): WorkerDatabase
|
||||
|
||||
@Binds
|
||||
abstract fun provideCoreNotificationDatabase(appDatabase: DriveDatabase): CoreNotificationDatabase
|
||||
|
||||
@Binds
|
||||
abstract fun providePushDatabase(appDatabase: DriveDatabase): PushDatabase
|
||||
|
||||
@Binds
|
||||
abstract fun provideTelemetryDatabase(appDatabase: DriveDatabase): TelemetryDatabase
|
||||
|
||||
@Binds
|
||||
abstract fun provideStatsDatabase(appDatabase: DriveDatabase): StatsDatabase
|
||||
|
||||
@Binds
|
||||
abstract fun providePhotoDatabase(appDatabase: DriveDatabase): PhotoDatabase
|
||||
|
||||
@Binds
|
||||
abstract fun provideDriveLinkPhotoDatabase(appDatabase: DriveDatabase): DriveLinkPhotoDatabase
|
||||
|
||||
@Binds
|
||||
abstract fun provideDriveFeatureFlagDatabase(appDatabase: DriveDatabase): DriveFeatureFlagDatabase
|
||||
|
||||
@Binds
|
||||
abstract fun provideMediaStoreVersionDatabase(appDatabase: DriveDatabase): MediaStoreVersionDatabase
|
||||
}
|
||||
|
||||
@@ -21,16 +21,27 @@ package me.proton.android.drive.extension
|
||||
import android.content.Context
|
||||
import me.proton.android.drive.lock.domain.exception.LockException
|
||||
import me.proton.core.drive.base.domain.exception.DriveException
|
||||
import me.proton.core.drive.share.domain.entity.Share
|
||||
import me.proton.core.drive.share.domain.exception.ShareException
|
||||
import me.proton.core.util.kotlin.CoreLogger
|
||||
import me.proton.android.drive.lock.presentation.extension.getDefaultMessage as lockGetDefaultMessage
|
||||
import me.proton.core.drive.i18n.R as I18N
|
||||
|
||||
fun DriveException.getDefaultMessage(context: Context): String = when (this) {
|
||||
is ShareException.MainShareLocked -> context.getString(I18N.string.error_main_share_locked)
|
||||
is ShareException.MainShareNotFound -> context.getString(I18N.string.error_main_share_not_found)
|
||||
is LockException -> lockGetDefaultMessage(context)
|
||||
else -> throw IllegalStateException("Default message for exception is missing")
|
||||
fun DriveException.getDefaultMessage(context: Context): String = when (val exception = this) {
|
||||
is ShareException.ShareLocked -> when (exception.shareType) {
|
||||
Share.Type.MAIN -> context.getString(I18N.string.error_main_share_locked)
|
||||
else -> context.getString(I18N.string.error_share_locked)
|
||||
}
|
||||
is ShareException.ShareNotFound -> when (exception.shareType) {
|
||||
Share.Type.MAIN -> context.getString(I18N.string.error_main_share_not_found)
|
||||
else -> context.getString(I18N.string.error_share_not_found)
|
||||
}
|
||||
is ShareException.CreatingShareNotAllowed -> when (exception.shareType) {
|
||||
Share.Type.PHOTO -> context.getString(I18N.string.error_creating_photo_share_not_allowed)
|
||||
else -> context.getString(I18N.string.error_creating_share_not_allowed)
|
||||
}
|
||||
is LockException -> exception.lockGetDefaultMessage(context)
|
||||
else -> error("Default message for exception is missing")
|
||||
}
|
||||
|
||||
fun DriveException.log(tag: String, message: String = this.message.orEmpty()): DriveException = also {
|
||||
|
||||
@@ -18,8 +18,10 @@
|
||||
|
||||
package me.proton.android.drive.extension
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import java.io.Serializable
|
||||
|
||||
fun NavBackStackEntry.requireArguments() = requireNotNull(arguments) { "arguments bundle is null" }
|
||||
|
||||
@@ -34,3 +36,33 @@ fun <T> NavBackStackEntry.get(key: String, optionalBundle: Bundle? = null): T? {
|
||||
val bundleArgs = requireArguments()
|
||||
return bundleArgs.getString(key) as T? ?: optionalBundle?.getString(key) as T?
|
||||
}
|
||||
|
||||
fun <T : Serializable> NavBackStackEntry.requireSerializable(
|
||||
key: String,
|
||||
clazz: Class<T>,
|
||||
optionalBundle: Bundle? = null
|
||||
): T {
|
||||
return requireNotNull(getSerializable(key, clazz, optionalBundle)) {
|
||||
"$key is required"
|
||||
}
|
||||
}
|
||||
|
||||
fun <T : Serializable> NavBackStackEntry.getSerializable(
|
||||
key: String,
|
||||
clazz: Class<T>,
|
||||
optionalBundle: Bundle? = null
|
||||
): T? {
|
||||
val bundleArgs = requireArguments()
|
||||
|
||||
return bundleArgs.getSerializableCompat(key, clazz)
|
||||
?: optionalBundle?.getSerializableCompat(key, clazz)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION", "UNCHECKED_CAST")
|
||||
fun <T : Serializable> Bundle.getSerializableCompat(key: String, clazz: Class<T>): T? =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
getSerializable(key, clazz)
|
||||
} else {
|
||||
getSerializable(key) as T?
|
||||
}
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ package me.proton.android.drive.extension
|
||||
|
||||
import android.content.Context
|
||||
import me.proton.core.drive.base.domain.exception.DriveException
|
||||
import me.proton.core.drive.base.presentation.extension.getDefaultMessage as baseGetDefaultMessage
|
||||
import me.proton.core.drive.base.presentation.extension.log as baseLog
|
||||
import me.proton.core.drive.base.data.extension.getDefaultMessage as baseGetDefaultMessage
|
||||
import me.proton.core.drive.base.data.extension.log as baseLog
|
||||
|
||||
fun Throwable.getDefaultMessage(
|
||||
context: Context,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.initializer
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import androidx.startup.Initializer
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import me.proton.android.drive.extension.log
|
||||
import me.proton.android.drive.photos.domain.usecase.RescanOnMediaStoreUpdate
|
||||
import me.proton.core.accountmanager.domain.AccountManager
|
||||
import me.proton.core.accountmanager.presentation.observe
|
||||
import me.proton.core.accountmanager.presentation.onAccountReady
|
||||
import me.proton.core.accountmanager.presentation.onAccountRemoved
|
||||
import me.proton.core.drive.backup.domain.entity.BackupErrorType
|
||||
import me.proton.core.drive.backup.domain.entity.BackupPermissions
|
||||
import me.proton.core.drive.backup.domain.handler.UploadErrorHandler
|
||||
import me.proton.core.drive.backup.domain.manager.BackupPermissionsManager
|
||||
import me.proton.core.drive.backup.domain.usecase.CheckAvailableSpace
|
||||
import me.proton.core.drive.backup.domain.usecase.HasFolders
|
||||
import me.proton.core.drive.backup.domain.usecase.StartBackupAfterErrorResolved
|
||||
import me.proton.core.drive.backup.domain.usecase.UnwatchFolders
|
||||
import me.proton.core.drive.backup.domain.usecase.WatchFolders
|
||||
import me.proton.core.drive.base.domain.extension.mapWithPrevious
|
||||
import me.proton.core.drive.base.domain.log.LogTag
|
||||
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
|
||||
import me.proton.core.drive.upload.domain.manager.UploadErrorManager
|
||||
import me.proton.core.presentation.app.AppLifecycleProvider
|
||||
import me.proton.core.user.domain.UserManager
|
||||
import me.proton.core.util.kotlin.CoreLogger
|
||||
|
||||
class BackupInitializer : Initializer<Unit> {
|
||||
override fun create(context: Context) {
|
||||
|
||||
EntryPointAccessors.fromApplication(
|
||||
context.applicationContext,
|
||||
BackupInitializerEntryPoint::class.java
|
||||
).run {
|
||||
if (!configurationProvider.photosFeatureFlag) {
|
||||
CoreLogger.d(LogTag.BACKUP, "Backup feature disabled")
|
||||
return
|
||||
}
|
||||
uploadErrorManager.errors
|
||||
.onEach(uploadErrorHandler::onError)
|
||||
.launchIn(appLifecycleProvider.lifecycle.coroutineScope)
|
||||
accountManager.observe(appLifecycleProvider.lifecycle, Lifecycle.State.STARTED)
|
||||
.onAccountReady { account ->
|
||||
val userId = account.userId
|
||||
|
||||
backupPermissionsManager.backupPermissions.mapWithPrevious { previous, permissions ->
|
||||
previous is BackupPermissions.Denied && permissions == BackupPermissions.Granted
|
||||
}.filter { acquirePermissions ->
|
||||
acquirePermissions
|
||||
}.onEach {
|
||||
startBackupAfterErrorResolved(
|
||||
userId = userId,
|
||||
type = BackupErrorType.PERMISSION,
|
||||
).onFailure { error ->
|
||||
error.log(LogTag.BACKUP, "Cannot restart the backup")
|
||||
}
|
||||
}.launchIn(appLifecycleProvider.lifecycle.coroutineScope)
|
||||
|
||||
hasFolders(userId).onEach { hasFolders ->
|
||||
if (hasFolders) {
|
||||
watchFolders(userId)
|
||||
} else {
|
||||
unwatchFolders(userId)
|
||||
}
|
||||
}.launchIn(appLifecycleProvider.lifecycle.coroutineScope)
|
||||
|
||||
userManager.observeUser(userId).filterNotNull().onEach { user ->
|
||||
checkAvailableSpace(user).onFailure { error ->
|
||||
error.log(LogTag.BACKUP, "Cannot check available space")
|
||||
}
|
||||
}.launchIn(appLifecycleProvider.lifecycle.coroutineScope)
|
||||
|
||||
rescanOnMediaStoreUpdate(userId).onFailure { error ->
|
||||
error.log("Cannot observe media store updates")
|
||||
}
|
||||
}
|
||||
.onAccountRemoved { account ->
|
||||
unwatchFolders(account.userId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun dependencies(): List<Class<out Initializer<*>>> = listOf(
|
||||
WorkManagerInitializer::class.java,
|
||||
)
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface BackupInitializerEntryPoint {
|
||||
val accountManager: AccountManager
|
||||
val checkAvailableSpace: CheckAvailableSpace
|
||||
val hasFolders: HasFolders
|
||||
val userManager: UserManager
|
||||
val watchFolders: WatchFolders
|
||||
val unwatchFolders: UnwatchFolders
|
||||
val uploadErrorHandler: UploadErrorHandler
|
||||
val uploadErrorManager: UploadErrorManager
|
||||
val backupPermissionsManager: BackupPermissionsManager
|
||||
val startBackupAfterErrorResolved: StartBackupAfterErrorResolved
|
||||
val appLifecycleProvider: AppLifecycleProvider
|
||||
val configurationProvider: ConfigurationProvider
|
||||
val rescanOnMediaStoreUpdate: RescanOnMediaStoreUpdate
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG
|
||||
* This file is part of Proton AG and Proton Pass.
|
||||
*
|
||||
* Proton Mail is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Mail is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Pass. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.initializer
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import androidx.startup.Initializer
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import me.proton.android.drive.BuildConfig
|
||||
import me.proton.android.drive.extension.log
|
||||
import me.proton.core.accountmanager.domain.AccountManager
|
||||
import me.proton.core.accountmanager.presentation.observe
|
||||
import me.proton.core.accountmanager.presentation.onAccountReady
|
||||
import me.proton.core.accountmanager.presentation.onAccountRemoved
|
||||
import me.proton.core.domain.entity.UserId
|
||||
import me.proton.core.drive.base.domain.log.LogTag
|
||||
import me.proton.core.drive.feature.flag.data.extension.toFeatureFlag
|
||||
import me.proton.core.drive.feature.flag.domain.usecase.GetObservableFeatureIds
|
||||
import me.proton.core.drive.feature.flag.domain.usecase.HandleFeatureFlags
|
||||
import me.proton.core.drive.feature.flag.domain.usecase.StartFeatureFlagRefreshPolling
|
||||
import me.proton.core.drive.feature.flag.domain.usecase.StopFeatureFlagRefreshPolling
|
||||
import me.proton.core.featureflag.data.FeatureFlagRefreshStarter
|
||||
import me.proton.core.featureflag.domain.repository.FeatureFlagRepository
|
||||
import me.proton.core.presentation.app.AppLifecycleProvider
|
||||
import me.proton.core.util.kotlin.takeIfNotEmpty
|
||||
|
||||
class FeatureFlagInitializer : Initializer<Unit> {
|
||||
|
||||
override fun create(context: Context) {
|
||||
with(
|
||||
EntryPointAccessors.fromApplication(
|
||||
context.applicationContext,
|
||||
FeatureFlagInitializerEntryPoint::class.java
|
||||
)
|
||||
) {
|
||||
featureFlagRefreshStarter().start(BuildConfig.DEBUG)
|
||||
val jobs: MutableMap<UserId, Job?> = mutableMapOf()
|
||||
accountManager.observe(appLifecycleProvider.lifecycle, Lifecycle.State.STARTED)
|
||||
.onAccountReady { account ->
|
||||
startFeatureFlagRefreshPolling(account.userId)
|
||||
.onFailure { error ->
|
||||
error.log(LogTag.FEATURE_FLAG)
|
||||
}
|
||||
jobs[account.userId] = notifyFeatureFlagChange(account.userId)
|
||||
}
|
||||
.onAccountRemoved { account ->
|
||||
jobs.remove(account.userId)?.cancel()
|
||||
stopFeatureFlagRefreshPolling(account.userId)
|
||||
.onFailure { error ->
|
||||
error.log(LogTag.FEATURE_FLAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun FeatureFlagInitializerEntryPoint.notifyFeatureFlagChange(userId: UserId): Job? =
|
||||
getObservableFeatureIds()
|
||||
.takeIfNotEmpty()
|
||||
?.let { featureIds ->
|
||||
featureFlagRepository.observe(userId, featureIds)
|
||||
.distinctUntilChanged()
|
||||
.onEach { featureFlags ->
|
||||
handleFeatureFlags(
|
||||
featureFlags.map { featureFlag -> featureFlag.toFeatureFlag(userId) }
|
||||
)
|
||||
}
|
||||
.launchIn(appLifecycleProvider.lifecycle.coroutineScope)
|
||||
}
|
||||
|
||||
override fun dependencies(): List<Class<out Initializer<*>?>> = listOf(
|
||||
WorkManagerInitializer::class.java
|
||||
)
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface FeatureFlagInitializerEntryPoint {
|
||||
fun featureFlagRefreshStarter(): FeatureFlagRefreshStarter
|
||||
val accountManager: AccountManager
|
||||
val appLifecycleProvider: AppLifecycleProvider
|
||||
val startFeatureFlagRefreshPolling: StartFeatureFlagRefreshPolling
|
||||
val stopFeatureFlagRefreshPolling: StopFeatureFlagRefreshPolling
|
||||
val featureFlagRepository: FeatureFlagRepository
|
||||
val handleFeatureFlags: HandleFeatureFlags
|
||||
val getObservableFeatureIds: GetObservableFeatureIds
|
||||
}
|
||||
}
|
||||
@@ -31,13 +31,12 @@ import me.proton.android.drive.log.DriveLogTag
|
||||
import me.proton.android.drive.log.DriveLogger
|
||||
import me.proton.android.drive.log.NoOpLogger
|
||||
import me.proton.android.drive.log.deviceInfo
|
||||
import me.proton.android.drive.usecase.GetFileLoggerTree
|
||||
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
|
||||
import me.proton.core.usersettings.domain.DeviceSettingsHandler
|
||||
import me.proton.core.usersettings.domain.onDeviceSettingsChanged
|
||||
import me.proton.core.usersettings.domain.UsersSettingsHandler
|
||||
import me.proton.core.util.kotlin.CoreLogger
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.Executors
|
||||
import me.proton.android.drive.usecase.GetFileLoggerTree
|
||||
|
||||
class LoggerInitializer : Initializer<Unit> {
|
||||
|
||||
@@ -47,15 +46,16 @@ class LoggerInitializer : Initializer<Unit> {
|
||||
LoggerInitializerEntryPoint::class.java
|
||||
)
|
||||
val logger = entryPoint.driveLogger()
|
||||
val handler = entryPoint.deviceSettingsHandler()
|
||||
|
||||
handler.onDeviceSettingsChanged { deviceSettings ->
|
||||
entryPoint.userSettingsHandler().onUsersSettingsChanged(
|
||||
merge = { usersSettings ->
|
||||
usersSettings.none { userSettings -> userSettings?.crashReports == false }
|
||||
}
|
||||
) { crashReports ->
|
||||
CoreLogger.set(
|
||||
logger = if (deviceSettings.isCrashReportEnabled) logger else NoOpLogger()
|
||||
logger = if (crashReports) logger else NoOpLogger()
|
||||
)
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
if (BuildConfig.DEBUG || BuildConfig.FLAVOR == BuildConfig.FLAVOR_ALPHA) {
|
||||
Timber.plant(Timber.DebugTree())
|
||||
if (entryPoint.configurationProvider().logToFileInDebugEnabled) {
|
||||
entryPoint.getFileLoggerTree().invoke()
|
||||
@@ -96,7 +96,7 @@ class LoggerInitializer : Initializer<Unit> {
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface LoggerInitializerEntryPoint {
|
||||
fun driveLogger(): DriveLogger
|
||||
fun deviceSettingsHandler(): DeviceSettingsHandler
|
||||
fun userSettingsHandler(): UsersSettingsHandler
|
||||
fun configurationProvider(): ConfigurationProvider
|
||||
fun getFileLoggerTree(): GetFileLoggerTree
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import android.content.Context
|
||||
import androidx.startup.AppInitializer
|
||||
import androidx.startup.Initializer
|
||||
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.plan.presentation.UnredeemedPurchaseInitializer
|
||||
@@ -33,12 +34,24 @@ class MainInitializer : Initializer<Unit> {
|
||||
}
|
||||
|
||||
override fun dependencies() = listOf(
|
||||
SentryInitializer::class.java,
|
||||
LoggerInitializer::class.java,
|
||||
FeatureFlagInitializer::class.java,
|
||||
AccountStateHandlerInitializer::class.java,
|
||||
AccountRemovedHandlerInitializer::class.java,
|
||||
NotificationChannelInitializer::class.java,
|
||||
DocumentsProviderInitializer::class.java,
|
||||
UncaughtExceptionHandlerInitializer::class.java,
|
||||
CryptoValidatorInitializer::class.java,
|
||||
EventManagerInitializer::class.java,
|
||||
HumanVerificationInitializer::class.java,
|
||||
UnredeemedPurchaseInitializer::class.java,
|
||||
MissingScopeInitializer::class.java,
|
||||
UnAuthSessionFetcherInitializer::class.java,
|
||||
AutoLockInitializer::class.java,
|
||||
BackupInitializer::class.java,
|
||||
TelemetryInitializer::class.java,
|
||||
SelectionInitializer::class.java,
|
||||
)
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.initializer
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.Lifecycle
|
||||
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.domain.AccountManager
|
||||
import me.proton.core.accountmanager.presentation.observe
|
||||
import me.proton.core.accountmanager.presentation.onAccountReady
|
||||
import me.proton.core.drive.link.selection.domain.usecase.CleanUpSelections
|
||||
import me.proton.core.presentation.app.AppLifecycleProvider
|
||||
|
||||
@Suppress("unused")
|
||||
class SelectionInitializer : Initializer<Unit> {
|
||||
|
||||
override fun create(context: Context) {
|
||||
with (
|
||||
EntryPointAccessors.fromApplication(
|
||||
context.applicationContext,
|
||||
SelectionInitializerEntryPoint::class.java
|
||||
)
|
||||
) {
|
||||
accountManager.observe(appLifecycleProvider.lifecycle, Lifecycle.State.STARTED)
|
||||
.onAccountReady { account ->
|
||||
cleanUpSelections(account.userId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun dependencies(): List<Class<out Initializer<*>>> = listOf(
|
||||
AccountStateHandlerInitializer::class.java,
|
||||
LoggerInitializer::class.java,
|
||||
)
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface SelectionInitializerEntryPoint {
|
||||
val accountManager: AccountManager
|
||||
val appLifecycleProvider: AppLifecycleProvider
|
||||
val cleanUpSelections: CleanUpSelections
|
||||
}
|
||||
}
|
||||
@@ -24,11 +24,13 @@ 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.android.drive.BuildConfig
|
||||
import me.proton.core.usersettings.domain.DeviceSettingsHandler
|
||||
import me.proton.core.usersettings.domain.onDeviceSettingsChanged
|
||||
import me.proton.core.usersettings.domain.UsersSettingsHandler
|
||||
import me.proton.core.util.android.sentry.TimberLoggerIntegration
|
||||
import me.proton.core.util.android.sentry.project.AccountSentryHubBuilder
|
||||
|
||||
class SentryInitializer : Initializer<Unit> {
|
||||
|
||||
@@ -38,17 +40,34 @@ class SentryInitializer : Initializer<Unit> {
|
||||
SentryInitializerEntryPoint::class.java
|
||||
)
|
||||
var isCrashReportEnabled = true
|
||||
entryPoint.deviceSettingsHandler().onDeviceSettingsChanged { deviceSettings ->
|
||||
isCrashReportEnabled = deviceSettings.isCrashReportEnabled
|
||||
entryPoint.usersSettingsHandler().onUsersSettingsChanged(
|
||||
merge = { usersSettings ->
|
||||
usersSettings.none { userSettings -> userSettings?.crashReports == false }
|
||||
}
|
||||
) { crashReports ->
|
||||
isCrashReportEnabled = crashReports
|
||||
}
|
||||
val beforeSendCallback = SentryOptions.BeforeSendCallback { event, _ ->
|
||||
if (isCrashReportEnabled) event else null
|
||||
}
|
||||
SentryAndroid.init(context) { options ->
|
||||
options.dsn = BuildConfig.SENTRY_DSN.takeIf { !BuildConfig.DEBUG }.orEmpty()
|
||||
options.release = BuildConfig.VERSION_NAME
|
||||
options.isEnableAutoSessionTracking = false
|
||||
options.environment = BuildConfig.FLAVOR
|
||||
options.beforeSend = SentryOptions.BeforeSendCallback { event, hint ->
|
||||
if (isCrashReportEnabled) event else null
|
||||
}
|
||||
options.beforeSend = beforeSendCallback
|
||||
options.addIntegration(
|
||||
TimberLoggerIntegration(
|
||||
minEventLevel = SentryLevel.ERROR,
|
||||
minBreadcrumbLevel = SentryLevel.DEBUG
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
entryPoint.accountSentryHubBuilder().invoke(
|
||||
sentryDsn = BuildConfig.ACCOUNT_SENTRY_DSN.takeIf { !BuildConfig.DEBUG }.orEmpty()
|
||||
) { options ->
|
||||
options.beforeSend = beforeSendCallback
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +76,7 @@ class SentryInitializer : Initializer<Unit> {
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface SentryInitializerEntryPoint {
|
||||
fun deviceSettingsHandler(): DeviceSettingsHandler
|
||||
fun accountSentryHubBuilder(): AccountSentryHubBuilder
|
||||
fun usersSettingsHandler(): UsersSettingsHandler
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package me.proton.android.drive.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.drive.telemetry.domain.event.PhotosEvent
|
||||
import me.proton.core.drive.telemetry.domain.extension.plus
|
||||
import me.proton.core.drive.telemetry.domain.filter.MeasurementGroupsFilter
|
||||
import me.proton.core.drive.telemetry.domain.interceptor.PlanDimensionInterceptor
|
||||
import me.proton.core.drive.telemetry.domain.manager.DriveTelemetryManager
|
||||
|
||||
class TelemetryInitializer : Initializer<Unit> {
|
||||
|
||||
override fun create(context: Context) {
|
||||
with(
|
||||
EntryPointAccessors.fromApplication(
|
||||
context.applicationContext,
|
||||
TelemetryInitializerEntryPoint::class.java
|
||||
)
|
||||
) {
|
||||
driveTelemetryManager.addInterceptor(
|
||||
MeasurementGroupsFilter(PhotosEvent.group) + planDimensionInterceptor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun dependencies(): List<Class<out Initializer<*>>> = listOf(
|
||||
LoggerInitializer::class.java,
|
||||
WorkManagerInitializer::class.java,
|
||||
)
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface TelemetryInitializerEntryPoint {
|
||||
val driveTelemetryManager: DriveTelemetryManager
|
||||
val planDimensionInterceptor: PlanDimensionInterceptor
|
||||
}
|
||||
}
|
||||
@@ -24,18 +24,13 @@ import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.LocaleList
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import io.sentry.Breadcrumb
|
||||
import io.sentry.Sentry
|
||||
import io.sentry.SentryEvent
|
||||
import io.sentry.SentryLevel
|
||||
import io.sentry.protocol.Message
|
||||
import me.proton.android.drive.BuildConfig
|
||||
import me.proton.core.drive.base.data.extension.getDefaultMessage
|
||||
import me.proton.core.drive.base.domain.entity.Percentage
|
||||
import me.proton.core.drive.base.domain.extension.percentageOfAsciiChars
|
||||
import me.proton.core.drive.base.presentation.extension.getDefaultMessage
|
||||
import me.proton.core.util.android.sentry.TimberLogger
|
||||
import me.proton.core.util.kotlin.Logger
|
||||
import me.proton.core.util.kotlin.LoggerLogTag
|
||||
import timber.log.Timber
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -44,41 +39,22 @@ import me.proton.core.drive.i18n.R as I18N
|
||||
@Singleton
|
||||
class DriveLogger @Inject constructor(
|
||||
@ApplicationContext private val appContext: Context,
|
||||
) : Logger {
|
||||
) : Logger by TimberLogger {
|
||||
|
||||
override fun v(tag: String, message: String) {
|
||||
DriveSentry.addBreadcrumb(tag, message)
|
||||
Timber.tag(tag).v(message)
|
||||
}
|
||||
override fun v(tag: String, e: Throwable, message: String) {
|
||||
DriveSentry.addBreadcrumb(tag, e, message)
|
||||
Timber.tag(tag).v(e, message)
|
||||
}
|
||||
override fun d(tag: String, message: String) {
|
||||
DriveSentry.addBreadcrumb(tag, message)
|
||||
Timber.tag(tag).d(message)
|
||||
}
|
||||
override fun d(tag: String, e: Throwable, message: String) {
|
||||
DriveSentry.addBreadcrumb(tag, e, message)
|
||||
Timber.tag(tag).d(e, message)
|
||||
}
|
||||
override fun i(tag: String, message: String) {
|
||||
DriveSentry.addBreadcrumb(tag, message, SentryLevel.INFO)
|
||||
Timber.tag(tag).i(message)
|
||||
}
|
||||
override fun i(tag: String, e: Throwable, message: String) {
|
||||
DriveSentry.addBreadcrumb(tag, e, message, SentryLevel.INFO)
|
||||
Timber.tag(tag).i(e, message)
|
||||
}
|
||||
override fun e(tag: String, e: Throwable) {
|
||||
DriveSentry.captureException(appContext, tag, e)
|
||||
Timber.tag(tag).e(e)
|
||||
DriveSentry.setInternalErrorTag(appContext, e)
|
||||
TimberLogger.e(tag, e)
|
||||
}
|
||||
|
||||
override fun e(tag: String, message: String) {
|
||||
DriveSentry.setInternalErrorTag(appContext, message)
|
||||
TimberLogger.e(tag, message)
|
||||
}
|
||||
|
||||
override fun e(tag: String, e: Throwable, message: String) {
|
||||
DriveSentry.captureException(appContext, tag, e, message)
|
||||
Timber.tag(tag).e(e, message)
|
||||
DriveSentry.setInternalErrorTag(appContext, e)
|
||||
TimberLogger.e(tag, e, message)
|
||||
}
|
||||
override fun log(tag: LoggerLogTag, message: String) = i(tag.name, message)
|
||||
|
||||
private fun withoutUploadFileContent(tag: String, message: String, block: (tag: String, message: String) -> Unit) {
|
||||
val isCoreNetwork = tag.startsWith("core.network")
|
||||
@@ -90,63 +66,18 @@ class DriveLogger @Inject constructor(
|
||||
}
|
||||
|
||||
private object DriveSentry {
|
||||
|
||||
fun captureException(
|
||||
context: Context,
|
||||
tag: String,
|
||||
e: Throwable,
|
||||
) {
|
||||
setInternalErrorTag(context, e)
|
||||
Sentry.setTag("CoreLogger", tag)
|
||||
Sentry.captureException(e)
|
||||
}
|
||||
|
||||
fun captureException(
|
||||
context: Context,
|
||||
tag: String,
|
||||
e: Throwable,
|
||||
message: String,
|
||||
level: SentryLevel = SentryLevel.ERROR,
|
||||
) {
|
||||
setInternalErrorTag(context, e)
|
||||
Sentry.setTag("CoreLogger", tag)
|
||||
Sentry.captureEvent(
|
||||
SentryEvent(e).apply {
|
||||
this.level = level
|
||||
this.message = Message().apply {
|
||||
this.message = message
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun addBreadcrumb(tag: String, message: String, level: SentryLevel = SentryLevel.DEBUG) {
|
||||
Sentry.addBreadcrumb(
|
||||
Breadcrumb().apply {
|
||||
this.category = tag.substringAfterLast('.')
|
||||
this.level = level
|
||||
this.message = message
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun addBreadcrumb(tag: String, e: Throwable, message: String, level: SentryLevel = SentryLevel.DEBUG) {
|
||||
Sentry.addBreadcrumb(
|
||||
Breadcrumb().apply {
|
||||
this.category = tag.substringAfterLast('.')
|
||||
this.level = level
|
||||
this.message = message
|
||||
this.setData("Throwable", e.stackTraceToString())
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun setInternalErrorTag(context: Context, e: Throwable) {
|
||||
val internalErrorMessage = context.getString(I18N.string.common_error_internal)
|
||||
val errorMessage = e.getDefaultMessage(
|
||||
fun setInternalErrorTag(context: Context, e: Throwable) {
|
||||
setInternalErrorTag(
|
||||
context = context,
|
||||
useExceptionMessage = false,
|
||||
errorMessage = e.getDefaultMessage(
|
||||
context = context,
|
||||
useExceptionMessage = false,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun setInternalErrorTag(context: Context, errorMessage: String) {
|
||||
val internalErrorMessage = context.getString(I18N.string.common_error_internal)
|
||||
Sentry.setTag("InternalError", (errorMessage == internalErrorMessage).toString())
|
||||
}
|
||||
}
|
||||
@@ -155,11 +86,14 @@ class DriveLogger @Inject constructor(
|
||||
class NoOpLogger : Logger {
|
||||
override fun d(tag: String, message: String) = Unit
|
||||
override fun d(tag: String, e: Throwable, message: String) = Unit
|
||||
override fun e(tag: String, message: String) = Unit
|
||||
override fun e(tag: String, e: Throwable) = Unit
|
||||
override fun e(tag: String, e: Throwable, message: String) = Unit
|
||||
override fun w(tag: String, message: String) = Unit
|
||||
override fun w(tag: String, e: Throwable) = Unit
|
||||
override fun w(tag: String, e: Throwable, message: String) = Unit
|
||||
override fun i(tag: String, message: String) = Unit
|
||||
override fun i(tag: String, e: Throwable, message: String) = Unit
|
||||
override fun log(tag: LoggerLogTag, message: String) = Unit
|
||||
override fun v(tag: String, message: String) = Unit
|
||||
override fun v(tag: String, e: Throwable, message: String) = Unit
|
||||
}
|
||||
@@ -167,6 +101,7 @@ class NoOpLogger : Logger {
|
||||
fun Logger.v(message: String) = v(DriveLogTag.DEFAULT, message)
|
||||
fun Logger.d(message: String) = d(DriveLogTag.DEFAULT, message)
|
||||
fun Logger.i(message: String) = i(DriveLogTag.DEFAULT, message)
|
||||
fun Logger.w(message: String) = w(DriveLogTag.DEFAULT, message)
|
||||
fun Logger.e(throwable: Throwable) = e(DriveLogTag.DEFAULT, throwable)
|
||||
|
||||
fun Logger.deviceInfo() {
|
||||
|
||||
+19
-12
@@ -19,14 +19,15 @@
|
||||
package me.proton.android.drive.notification
|
||||
|
||||
import androidx.core.app.NotificationCompat
|
||||
import me.proton.android.drive.usecase.notification.BackupNotificationBuilder
|
||||
import me.proton.android.drive.usecase.notification.DownloadNotificationBuilder
|
||||
import me.proton.android.drive.usecase.notification.ForcedSignOutNotificationBuilder
|
||||
import me.proton.android.drive.usecase.notification.NoSpaceLeftOnDeviceNotificationBuilder
|
||||
import me.proton.android.drive.usecase.notification.StorageFullNotificationBuilder
|
||||
import me.proton.android.drive.usecase.notification.UploadNotificationBuilder
|
||||
import me.proton.core.drive.announce.event.domain.entity.Event
|
||||
import me.proton.core.drive.base.domain.extension.requireIsInstance
|
||||
import me.proton.core.drive.notification.data.provider.NotificationBuilderProvider
|
||||
import me.proton.core.drive.notification.domain.entity.NotificationEvent
|
||||
import me.proton.core.drive.notification.domain.entity.NotificationId
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -36,37 +37,43 @@ class AppNotificationBuilderProvider @Inject constructor(
|
||||
private val downloadNotificationBuilder: DownloadNotificationBuilder,
|
||||
private val forcedSignOutNotificationBuilder: ForcedSignOutNotificationBuilder,
|
||||
private val noSpaceLeftOnDeviceNotificationBuilder: NoSpaceLeftOnDeviceNotificationBuilder,
|
||||
private val backupNotificationBuilder: BackupNotificationBuilder,
|
||||
) : NotificationBuilderProvider {
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun get(
|
||||
notificationId: NotificationId,
|
||||
notificationEvents: List<NotificationEvent>,
|
||||
events: List<Event>,
|
||||
): NotificationCompat.Builder = when {
|
||||
notificationEvents.size == 1 && notificationEvents.first() is NotificationEvent.StorageFull ->
|
||||
events.size == 1 && events.first() is Event.StorageFull ->
|
||||
storageFullBuilder(
|
||||
notificationId = requireIsInstance(notificationId),
|
||||
notificationEvent = notificationEvents.first() as NotificationEvent.StorageFull,
|
||||
event = events.first() as Event.StorageFull,
|
||||
)
|
||||
notificationEvents.isNotEmpty() && notificationEvents.all { event -> event is NotificationEvent.Upload } ->
|
||||
events.isNotEmpty() && events.all { event -> event is Event.Upload } ->
|
||||
uploadNotificationBuilder(
|
||||
notificationId = requireIsInstance(notificationId),
|
||||
notificationEvents = notificationEvents as List<NotificationEvent.Upload>,
|
||||
events = events as List<Event.Upload>,
|
||||
)
|
||||
notificationEvents.size == 1 && notificationEvents.first() is NotificationEvent.Download ->
|
||||
events.size == 1 && events.first() is Event.Download ->
|
||||
downloadNotificationBuilder(
|
||||
notificationId = requireIsInstance(notificationId),
|
||||
notificationEvent = notificationEvents.first() as NotificationEvent.Download,
|
||||
event = events.first() as Event.Download,
|
||||
)
|
||||
notificationEvents.size == 1 && notificationEvents.first() is NotificationEvent.ForcedSignOut ->
|
||||
events.size == 1 && events.first() is Event.ForcedSignOut ->
|
||||
forcedSignOutNotificationBuilder(
|
||||
notificationId = requireIsInstance(notificationId),
|
||||
notificationEvent = notificationEvents.first() as NotificationEvent.ForcedSignOut,
|
||||
event = events.first() as Event.ForcedSignOut,
|
||||
)
|
||||
notificationEvents.size == 1 && notificationEvents.first() is NotificationEvent.NoSpaceLeftOnDevice ->
|
||||
events.size == 1 && events.first() is Event.NoSpaceLeftOnDevice ->
|
||||
noSpaceLeftOnDeviceNotificationBuilder(
|
||||
notificationId = requireIsInstance(notificationId),
|
||||
notificationEvent = notificationEvents.first() as NotificationEvent.NoSpaceLeftOnDevice,
|
||||
event = events.first() as Event.NoSpaceLeftOnDevice,
|
||||
)
|
||||
events.size == 1 && events.first() is Event.Backup ->
|
||||
backupNotificationBuilder(
|
||||
notificationId = requireIsInstance(notificationId),
|
||||
event = events.first() as Event.Backup,
|
||||
)
|
||||
else -> error("Unhandled notification events")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.notification
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import me.proton.android.drive.usecase.notification.AcceptNotificationEvent
|
||||
import me.proton.android.drive.usecase.notification.CreateUserNotificationId
|
||||
import me.proton.android.drive.usecase.notification.ShouldCancelNotification
|
||||
import me.proton.core.domain.entity.UserId
|
||||
import me.proton.core.drive.announce.event.domain.entity.Event
|
||||
import me.proton.core.drive.announce.event.domain.handler.EventHandler
|
||||
import me.proton.core.drive.base.domain.log.LogTag
|
||||
import me.proton.core.drive.notification.data.extension.createNotificationId
|
||||
import me.proton.core.drive.notification.domain.entity.NotificationId
|
||||
import me.proton.core.drive.notification.domain.usecase.CancelAndRemoveNotification
|
||||
import me.proton.core.drive.notification.domain.usecase.SaveAndPublishNotification
|
||||
import me.proton.core.util.kotlin.CoreLogger
|
||||
import javax.inject.Inject
|
||||
|
||||
class NotificationEventHandler @Inject constructor(
|
||||
private val saveAndPublishNotification: SaveAndPublishNotification,
|
||||
private val acceptNotificationEvent: AcceptNotificationEvent,
|
||||
private val shouldCancelNotification: ShouldCancelNotification,
|
||||
private val cancelAndRemoveNotification: CancelAndRemoveNotification,
|
||||
private val createUserNotificationId: CreateUserNotificationId,
|
||||
) : EventHandler {
|
||||
private val mutex = Mutex()
|
||||
|
||||
override suspend fun onEvent(userId: UserId, event: Event) {
|
||||
val notificationId = createUserNotificationId(userId, event)
|
||||
onNotificationEvent(
|
||||
notificationId = notificationId,
|
||||
event = event,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun onEvent(event: Event) {
|
||||
val notificationId = event.createNotificationId()
|
||||
onNotificationEvent(
|
||||
notificationId = notificationId,
|
||||
event = event,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun onNotificationEvent(
|
||||
notificationId: NotificationId,
|
||||
event: Event,
|
||||
) = mutex.withLock {
|
||||
if (acceptNotificationEvent(notificationId, event)) {
|
||||
saveAndPublishNotification(notificationId, event)
|
||||
.onFailure { error ->
|
||||
CoreLogger.d(LogTag.NOTIFICATION, error, "Save and publish notification failed")
|
||||
}
|
||||
if (shouldCancelNotification(notificationId, event)) {
|
||||
cancelAndRemoveNotification(notificationId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -24,11 +24,13 @@ import android.content.Intent
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import me.proton.android.drive.extension.log
|
||||
import me.proton.core.drive.base.domain.extension.resultValueOrNull
|
||||
import me.proton.core.drive.base.domain.log.LogTag
|
||||
import me.proton.core.drive.base.domain.util.coRunCatching
|
||||
import me.proton.core.drive.notification.domain.entity.NotificationId
|
||||
import me.proton.core.drive.notification.domain.usecase.CancelAndRemoveNotification
|
||||
import me.proton.core.drive.notification.domain.usecase.RemoveNotification
|
||||
import me.proton.core.drive.share.domain.usecase.GetMainShare
|
||||
import me.proton.core.drive.upload.domain.usecase.CancelAllUpload
|
||||
import me.proton.core.util.kotlin.CoreLogger
|
||||
import me.proton.core.util.kotlin.deserialize
|
||||
@@ -40,6 +42,7 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
|
||||
@Inject lateinit var removeNotification: RemoveNotification
|
||||
@Inject lateinit var cancelAndRemoveNotification: CancelAndRemoveNotification
|
||||
@Inject lateinit var cancelAllUpload: CancelAllUpload
|
||||
@Inject lateinit var getMainShare: GetMainShare
|
||||
|
||||
override fun onReceive(context: Context?, intent: Intent?) = intent?.action?.let { action ->
|
||||
val notificationIdString = intent.getStringExtra(EXTRA_NOTIFICATION_ID) ?: return
|
||||
@@ -59,7 +62,12 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
|
||||
if (notificationId is NotificationId.User) {
|
||||
when (action) {
|
||||
ACTION_DELETE -> removeNotification(notificationId)
|
||||
ACTION_CANCEL_ALL -> cancelAllUpload(notificationId.channel.userId)
|
||||
ACTION_CANCEL_ALL -> getMainShare(notificationId.channel.userId).resultValueOrNull()?.id?.let { shareId ->
|
||||
cancelAllUpload(
|
||||
notificationId.channel.userId,
|
||||
shareId,
|
||||
)
|
||||
}
|
||||
else -> CoreLogger.e(
|
||||
tag = LogTag.BROADCAST_RECEIVER,
|
||||
e = RuntimeException("Received unknown action '$action'")
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.repository
|
||||
|
||||
import kotlinx.coroutines.flow.first
|
||||
import me.proton.android.drive.photos.data.repository.PhotoFindDuplicatesRepository
|
||||
import me.proton.core.domain.arch.mapSuccessValueOrNull
|
||||
import me.proton.core.drive.backup.data.repository.FolderFindDuplicatesRepository
|
||||
import me.proton.core.drive.backup.domain.entity.BackupDuplicate
|
||||
import me.proton.core.drive.backup.domain.repository.FindDuplicatesRepository
|
||||
import me.proton.core.drive.base.domain.entity.ClientUid
|
||||
import me.proton.core.drive.base.domain.extension.filterSuccessOrError
|
||||
import me.proton.core.drive.base.domain.log.logId
|
||||
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
|
||||
import me.proton.core.drive.link.domain.entity.FolderId
|
||||
import me.proton.core.drive.share.domain.entity.Share
|
||||
import me.proton.core.drive.share.domain.usecase.GetShare
|
||||
import javax.inject.Inject
|
||||
|
||||
class BridgeFindDuplicatesRepository @Inject constructor(
|
||||
private val getShare: GetShare,
|
||||
private val configurationProvider: ConfigurationProvider,
|
||||
private val folder: FolderFindDuplicatesRepository,
|
||||
private val photo: PhotoFindDuplicatesRepository,
|
||||
) : FindDuplicatesRepository {
|
||||
override suspend fun findDuplicates(
|
||||
folderId: FolderId,
|
||||
nameHashes: List<String>,
|
||||
clientUids: List<ClientUid>,
|
||||
): List<BackupDuplicate> {
|
||||
val share = getShare(folderId.shareId)
|
||||
.filterSuccessOrError().mapSuccessValueOrNull().first()
|
||||
requireNotNull(share) { "Cannot find share for folder: ${folderId.id.logId()}" }
|
||||
return if (share.type == Share.Type.PHOTO) {
|
||||
photo.findDuplicates(folderId, nameHashes, clientUids)
|
||||
} else {
|
||||
folder.findDuplicates(folderId, nameHashes, clientUids)
|
||||
}.let { backupDuplicates ->
|
||||
if (configurationProvider.allowBackupDeletedFilesEnabled) {
|
||||
backupDuplicates.filterNot { duplicate ->
|
||||
duplicate.linkState == null
|
||||
}
|
||||
} else {
|
||||
backupDuplicates
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.repository
|
||||
|
||||
import me.proton.android.drive.db.ClientUidDatabase
|
||||
import me.proton.android.drive.db.entity.ClientUidEntity
|
||||
import me.proton.core.drive.base.domain.entity.ClientUid
|
||||
import me.proton.core.drive.base.domain.repository.ClientUidRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
class ClientUidRepositoryImpl @Inject constructor(
|
||||
private val db: ClientUidDatabase,
|
||||
) : ClientUidRepository {
|
||||
override suspend fun get(): ClientUid? = db.clientUidDao.get().let { entity ->
|
||||
entity?.key
|
||||
}
|
||||
|
||||
override suspend fun insert(clientUid: ClientUid) {
|
||||
db.clientUidDao.insertOrUpdate(ClientUidEntity(clientUid))
|
||||
}
|
||||
}
|
||||
@@ -21,25 +21,33 @@ package me.proton.android.drive.settings
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import me.proton.android.drive.provider.BuildConfigurationProvider
|
||||
import me.proton.core.drive.base.data.datastore.BaseDataStore
|
||||
import me.proton.core.drive.base.data.datastore.asFlow
|
||||
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
class DebugSettings(
|
||||
@ApplicationContext private val context: Context,
|
||||
buildConfig: BuildConfigurationProvider
|
||||
private val buildConfig: BuildConfigurationProvider,
|
||||
) : BaseDataStore(DEBUG_SETTINGS_PREFERENCES), ConfigurationProvider {
|
||||
private val prefsKeyHost = stringPreferencesKey(HOST)
|
||||
private val prefsKeyBaseUrl = stringPreferencesKey(BASE_URL)
|
||||
private val prefsKeyAppVersionHeader = stringPreferencesKey(APP_VERSION_HEADER)
|
||||
private val prefsUseExceptionMessage = booleanPreferencesKey(USE_EXCEPTION_MESSAGE)
|
||||
private val prefsLogToFileEnabled = booleanPreferencesKey(LOG_TO_FILE_ENABLED)
|
||||
private val prefsAllowBackupDeletedFilesEnabled = booleanPreferencesKey(ALLOW_BACKUP_DELETED_FILES_ENABLED)
|
||||
private val prefsFeatureFlagFreshDuration = longPreferencesKey(FEATURE_FLAG_FRESH_DURATION)
|
||||
private val prefsUseVerifier = booleanPreferencesKey(USE_VERIFIER)
|
||||
val baseUrlFlow: Flow<String> = prefsKeyBaseUrl.asFlow(
|
||||
dataStore = context.dataStore,
|
||||
default = buildConfig.baseUrl
|
||||
@@ -60,6 +68,18 @@ class DebugSettings(
|
||||
dataStore = context.dataStore,
|
||||
default = buildConfig.logToFileInDebugEnabled,
|
||||
)
|
||||
val allowBackupDeletedFilesEnabledFlow: Flow<Boolean> = prefsAllowBackupDeletedFilesEnabled.asFlow(
|
||||
dataStore = context.dataStore,
|
||||
default = buildConfig.allowBackupDeletedFilesEnabled,
|
||||
)
|
||||
val featureFlagFreshDurationFlow: Flow<Long> = prefsFeatureFlagFreshDuration.asFlow(
|
||||
dataStore = context.dataStore,
|
||||
default = buildConfig.featureFlagFreshDuration.inWholeMinutes,
|
||||
)
|
||||
val useVerifierFlow: Flow<Boolean> = prefsUseVerifier.asFlow(
|
||||
dataStore = context.dataStore,
|
||||
default = buildConfig.useVerifier,
|
||||
)
|
||||
|
||||
override var baseUrl by Delegate(
|
||||
dataStore = context.dataStore,
|
||||
@@ -86,6 +106,30 @@ class DebugSettings(
|
||||
key = prefsLogToFileEnabled,
|
||||
default = buildConfig.logToFileInDebugEnabled,
|
||||
)
|
||||
override var allowBackupDeletedFilesEnabled by Delegate(
|
||||
dataStore = context.dataStore,
|
||||
key = prefsAllowBackupDeletedFilesEnabled,
|
||||
default = buildConfig.allowBackupDeletedFilesEnabled,
|
||||
)
|
||||
override var featureFlagFreshDuration: Duration
|
||||
get() = runBlocking {
|
||||
prefsFeatureFlagFreshDuration.asFlow(
|
||||
context.dataStore,
|
||||
buildConfig.featureFlagFreshDuration.inWholeMinutes,
|
||||
).first().minutes
|
||||
}
|
||||
set(value) = runBlocking {
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[prefsFeatureFlagFreshDuration] = value.inWholeMinutes
|
||||
}
|
||||
}
|
||||
override var photosSavedCounter : Boolean = true
|
||||
override var photoExportData : Boolean = true
|
||||
override var useVerifier by Delegate(
|
||||
dataStore = context.dataStore,
|
||||
key = prefsUseVerifier,
|
||||
default = buildConfig.useVerifier,
|
||||
)
|
||||
|
||||
fun reset(coroutineScope: CoroutineScope) {
|
||||
coroutineScope.launch {
|
||||
@@ -102,5 +146,8 @@ class DebugSettings(
|
||||
const val APP_VERSION_HEADER = "app_version_header"
|
||||
const val USE_EXCEPTION_MESSAGE = "use_exception_message"
|
||||
const val LOG_TO_FILE_ENABLED = "log_to_file_enabled"
|
||||
const val ALLOW_BACKUP_DELETED_FILES_ENABLED = "allow_backup_deleted_files_enabled"
|
||||
const val FEATURE_FLAG_FRESH_DURATION = "feature_flag_fresh_duration"
|
||||
const val USE_VERIFIER = "use_verifier"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.stats
|
||||
|
||||
import me.proton.core.domain.entity.UserId
|
||||
import me.proton.core.drive.base.domain.extension.toResult
|
||||
import me.proton.core.drive.link.domain.extension.rootFolderId
|
||||
import me.proton.core.drive.share.crypto.domain.usecase.GetPhotoShare
|
||||
import me.proton.core.drive.stats.domain.usecase.DeleteUploadStats
|
||||
import me.proton.core.drive.stats.domain.usecase.SetOrIgnoreInitialBackup
|
||||
import javax.inject.Inject
|
||||
|
||||
class BackupCompletedSideEffect @Inject constructor(
|
||||
private val getPhotoShare: GetPhotoShare,
|
||||
private val deleteUploadStats: DeleteUploadStats,
|
||||
private val setOrIgnoreInitialBackup: SetOrIgnoreInitialBackup,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(userId: UserId) {
|
||||
getPhotoShare(userId).toResult().getOrNull()?.let { share ->
|
||||
setOrIgnoreInitialBackup(share.rootFolderId).getOrThrow()
|
||||
deleteUploadStats(share.rootFolderId).getOrThrow()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.stats
|
||||
|
||||
import me.proton.core.domain.entity.UserId
|
||||
import me.proton.core.drive.base.domain.entity.TimestampS
|
||||
import me.proton.core.drive.base.domain.extension.bytes
|
||||
import me.proton.core.drive.base.domain.extension.toResult
|
||||
import me.proton.core.drive.link.domain.extension.rootFolderId
|
||||
import me.proton.core.drive.share.crypto.domain.usecase.GetPhotoShare
|
||||
import me.proton.core.drive.stats.domain.entity.UploadStats
|
||||
import me.proton.core.drive.stats.domain.usecase.UpdateUploadStats
|
||||
import javax.inject.Inject
|
||||
|
||||
class BackupStartedSideEffect(
|
||||
private val getPhotoShare: GetPhotoShare,
|
||||
private val updateUploadStats: UpdateUploadStats,
|
||||
private val clock: () -> TimestampS,
|
||||
) {
|
||||
@Inject
|
||||
constructor(
|
||||
getPhotoShare: GetPhotoShare,
|
||||
updateUploadStats: UpdateUploadStats,
|
||||
) : this(getPhotoShare, updateUploadStats, ::TimestampS)
|
||||
|
||||
suspend operator fun invoke(userId: UserId) {
|
||||
getPhotoShare(userId).toResult().getOrNull()?.let { share ->
|
||||
updateUploadStats(
|
||||
UploadStats(
|
||||
folderId = share.rootFolderId,
|
||||
count = 0,
|
||||
size = 0.bytes,
|
||||
minimumUploadCreationDateTime = clock(),
|
||||
minimumFileCreationDateTime = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.stats
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import me.proton.core.domain.entity.UserId
|
||||
import me.proton.core.drive.announce.event.domain.entity.Event
|
||||
import me.proton.core.drive.announce.event.domain.entity.Event.BackupStarted
|
||||
import me.proton.core.drive.announce.event.domain.entity.Event.Upload
|
||||
import me.proton.core.drive.announce.event.domain.handler.EventHandler
|
||||
import javax.inject.Inject
|
||||
|
||||
class StatsEventHandler @Inject constructor(
|
||||
private val backupCompletedSideEffect: BackupCompletedSideEffect,
|
||||
private val backupStartedSideEffect: BackupStartedSideEffect,
|
||||
private val uploadSideEffect: UploadSideEffect,
|
||||
) : EventHandler {
|
||||
|
||||
private val mutex = Mutex()
|
||||
override suspend fun onEvent(
|
||||
userId: UserId,
|
||||
event: Event,
|
||||
) = mutex.withLock {
|
||||
when (event) {
|
||||
BackupStarted -> backupStartedSideEffect(userId)
|
||||
is Event.BackupCompleted -> backupCompletedSideEffect(userId)
|
||||
is Upload -> uploadSideEffect(event)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.stats
|
||||
|
||||
import me.proton.core.drive.announce.event.domain.entity.Event
|
||||
import me.proton.core.drive.base.domain.extension.toResult
|
||||
import me.proton.core.drive.linkupload.domain.usecase.GetUploadFileLink
|
||||
import me.proton.core.drive.stats.domain.entity.UploadStats
|
||||
import me.proton.core.drive.stats.domain.usecase.UpdateUploadStats
|
||||
import javax.inject.Inject
|
||||
|
||||
class UploadSideEffect @Inject constructor(
|
||||
private val getUploadFileLink: GetUploadFileLink,
|
||||
private val updateUploadStats: UpdateUploadStats,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(event: Event.Upload) {
|
||||
if (event.state in finalState) {
|
||||
val uploadFileLink = getUploadFileLink(event.uploadFileLinkId).toResult().getOrThrow()
|
||||
val uploadCreationDateTime = requireNotNull(uploadFileLink.uploadCreationDateTime)
|
||||
val size = requireNotNull(uploadFileLink.size)
|
||||
updateUploadStats(
|
||||
UploadStats(
|
||||
folderId = uploadFileLink.parentLinkId,
|
||||
count = 1,
|
||||
size = size,
|
||||
minimumUploadCreationDateTime = uploadCreationDateTime,
|
||||
minimumFileCreationDateTime = uploadFileLink.fileCreationDateTime,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val finalState = listOf(
|
||||
Event.Upload.UploadState.UPLOAD_COMPLETE,
|
||||
Event.Upload.UploadState.UPLOAD_FAILED,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.telemetry
|
||||
|
||||
import me.proton.core.domain.entity.UserId
|
||||
import me.proton.core.drive.base.domain.extension.toResult
|
||||
import me.proton.core.drive.link.domain.extension.rootFolderId
|
||||
import me.proton.core.drive.share.crypto.domain.usecase.GetPhotoShare
|
||||
import me.proton.core.drive.telemetry.domain.event.PhotosEvent.Reason
|
||||
import javax.inject.Inject
|
||||
|
||||
class BackupCompletedEventMapper @Inject constructor(
|
||||
private val getPhotoShare: GetPhotoShare,
|
||||
private val createPhotosEventBackupStopped: CreatePhotosEventBackupStopped,
|
||||
) {
|
||||
suspend operator fun invoke(
|
||||
userId: UserId,
|
||||
) = getPhotoShare(userId).toResult().getOrNull()?.let { share ->
|
||||
createPhotosEventBackupStopped(
|
||||
folderId = share.rootFolderId,
|
||||
reason = Reason.COMPLETED,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.telemetry
|
||||
|
||||
import me.proton.core.domain.entity.UserId
|
||||
import me.proton.core.drive.announce.event.domain.entity.Event
|
||||
import me.proton.core.drive.announce.event.domain.entity.Event.Backup.BackupState
|
||||
import me.proton.core.drive.base.domain.extension.toResult
|
||||
import me.proton.core.drive.link.domain.extension.rootFolderId
|
||||
import me.proton.core.drive.share.crypto.domain.usecase.GetPhotoShare
|
||||
import me.proton.core.drive.telemetry.domain.event.PhotosEvent.Reason
|
||||
import javax.inject.Inject
|
||||
|
||||
class BackupStoppedEventMapper @Inject constructor(
|
||||
private val getPhotoShare: GetPhotoShare,
|
||||
private val createPhotosEventBackupStopped: CreatePhotosEventBackupStopped,
|
||||
) {
|
||||
suspend operator fun invoke(userId: UserId, event: Event.BackupStopped) = when (event.state) {
|
||||
BackupState.FAILED -> Reason.FAILED_OTHER
|
||||
BackupState.FAILED_CONNECTIVITY -> Reason.PAUSED_CONNECTIVITY
|
||||
BackupState.FAILED_PERMISSION -> Reason.FAILED_PERMISSIONS
|
||||
BackupState.FAILED_LOCAL_STORAGE -> Reason.FAILED_LOCAL_STORAGE
|
||||
BackupState.FAILED_DRIVE_STORAGE -> Reason.FAILED_DRIVE_STORAGE
|
||||
BackupState.FAILED_PHOTOS_UPLOAD_NOT_ALLOWED -> Reason.FAILED_NOT_ALLOWED
|
||||
BackupState.PAUSED_DISABLED -> Reason.PAUSED_DISABLED
|
||||
else -> null
|
||||
}?.let { reason ->
|
||||
getPhotoShare(userId).toResult().getOrNull()?.let { share ->
|
||||
createPhotosEventBackupStopped(
|
||||
folderId = share.rootFolderId,
|
||||
reason = reason,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.telemetry
|
||||
|
||||
import me.proton.core.drive.base.domain.entity.TimestampS
|
||||
import me.proton.core.drive.link.domain.entity.FolderId
|
||||
import me.proton.core.drive.stats.domain.entity.UploadStats
|
||||
import me.proton.core.drive.stats.domain.usecase.GetUploadStats
|
||||
import me.proton.core.drive.stats.domain.usecase.IsInitialBackup
|
||||
import me.proton.core.drive.telemetry.domain.entity.DriveTelemetryEvent
|
||||
import me.proton.core.drive.telemetry.domain.event.PhotosEvent
|
||||
import javax.inject.Inject
|
||||
|
||||
class CreatePhotosEventBackupStopped(
|
||||
private val getUploadStats: GetUploadStats,
|
||||
private val isInitialBackup: IsInitialBackup,
|
||||
private val clock: () -> TimestampS,
|
||||
) {
|
||||
@Inject
|
||||
constructor(
|
||||
getUploadStats: GetUploadStats,
|
||||
isInitialBackup: IsInitialBackup,
|
||||
) : this(getUploadStats, isInitialBackup, ::TimestampS)
|
||||
|
||||
suspend operator fun invoke(
|
||||
folderId: FolderId,
|
||||
reason: PhotosEvent.Reason,
|
||||
): DriveTelemetryEvent = if (reason == PhotosEvent.Reason.PAUSED_DISABLED) {
|
||||
getUploadStats(folderId).getOrNull()
|
||||
?.backupStop(reason, folderId)
|
||||
?: backupStopNoBackup(reason, folderId)
|
||||
} else {
|
||||
getUploadStats(folderId).getOrThrow().backupStop(reason, folderId)
|
||||
}
|
||||
|
||||
private suspend fun UploadStats.backupStop(
|
||||
reason: PhotosEvent.Reason,
|
||||
folderId: FolderId,
|
||||
) = PhotosEvent.BackupStopped(
|
||||
duration = clock().value - minimumUploadCreationDateTime.value,
|
||||
files = count,
|
||||
size = size.value,
|
||||
reason = reason,
|
||||
isInitialBackup = isInitialBackup(folderId).getOrThrow()
|
||||
)
|
||||
|
||||
private suspend fun backupStopNoBackup(
|
||||
reason: PhotosEvent.Reason,
|
||||
folderId: FolderId,
|
||||
) = PhotosEvent.BackupStopped(
|
||||
duration = 0,
|
||||
files = 0,
|
||||
size = 0,
|
||||
reason = reason,
|
||||
isInitialBackup = isInitialBackup(folderId).getOrThrow()
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.telemetry
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import me.proton.core.domain.entity.UserId
|
||||
import me.proton.core.drive.announce.event.domain.entity.Event
|
||||
import me.proton.core.drive.announce.event.domain.entity.Event.BackupCompleted
|
||||
import me.proton.core.drive.announce.event.domain.entity.Event.BackupDisabled
|
||||
import me.proton.core.drive.announce.event.domain.entity.Event.BackupEnabled
|
||||
import me.proton.core.drive.announce.event.domain.entity.Event.BackupStopped
|
||||
import me.proton.core.drive.announce.event.domain.entity.Event.Upload
|
||||
import me.proton.core.drive.announce.event.domain.handler.EventHandler
|
||||
import me.proton.core.drive.telemetry.domain.event.PhotosEvent
|
||||
import me.proton.core.drive.telemetry.domain.manager.DriveTelemetryManager
|
||||
import javax.inject.Inject
|
||||
|
||||
class TelemetryEventHandler @Inject constructor(
|
||||
private val manager: DriveTelemetryManager,
|
||||
private val backupCompletedEventMapper: BackupCompletedEventMapper,
|
||||
private val backupStoppedEventMapper: BackupStoppedEventMapper,
|
||||
private val uploadEventMapper: UploadEventMapper,
|
||||
) : EventHandler {
|
||||
|
||||
private val mutex = Mutex()
|
||||
override suspend fun onEvent(
|
||||
userId: UserId,
|
||||
event: Event,
|
||||
) = mutex.withLock {
|
||||
when (event) {
|
||||
BackupCompleted -> backupCompletedEventMapper(userId)
|
||||
BackupDisabled -> PhotosEvent.SettingDisabled()
|
||||
BackupEnabled -> PhotosEvent.SettingEnabled()
|
||||
is BackupStopped -> backupStoppedEventMapper(userId, event)
|
||||
is Upload -> uploadEventMapper(event)
|
||||
else -> null
|
||||
}?.let { event ->
|
||||
manager.enqueue(userId, event)
|
||||
}
|
||||
Unit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.telemetry
|
||||
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import me.proton.core.drive.announce.event.domain.entity.Event
|
||||
import me.proton.core.drive.announce.event.domain.entity.Event.Upload.Reason
|
||||
import me.proton.core.drive.base.domain.entity.Bytes
|
||||
import me.proton.core.drive.base.domain.entity.TimestampS
|
||||
import me.proton.core.drive.base.domain.extension.filterSuccessOrError
|
||||
import me.proton.core.drive.base.domain.extension.toResult
|
||||
import me.proton.core.drive.linkupload.domain.entity.UploadFileLink
|
||||
import me.proton.core.drive.linkupload.domain.usecase.GetUploadFileLink
|
||||
import me.proton.core.drive.share.domain.entity.Share
|
||||
import me.proton.core.drive.share.domain.usecase.GetShare
|
||||
import me.proton.core.drive.telemetry.domain.event.PhotosEvent
|
||||
import javax.inject.Inject
|
||||
|
||||
class UploadEventMapper(
|
||||
private val getUploadFileLink: GetUploadFileLink,
|
||||
private val getShare: GetShare,
|
||||
private val clock: () -> TimestampS,
|
||||
) {
|
||||
@Inject
|
||||
constructor(
|
||||
getUploadFileLink: GetUploadFileLink,
|
||||
getShare: GetShare,
|
||||
) : this(getUploadFileLink, getShare, clock = { TimestampS() })
|
||||
|
||||
suspend operator fun invoke(event: Event.Upload) =
|
||||
if (event.state in finalState) {
|
||||
getUploadFileLink(event.uploadFileLinkId).toResult().getOrThrow()
|
||||
.takeIf { uploadFileLink -> uploadFileLink.isPhoto() }
|
||||
?.let { uploadFileLink ->
|
||||
val uploadCreationDateTime =
|
||||
requireNotNull(uploadFileLink.uploadCreationDateTime)
|
||||
val size = requireNotNull(uploadFileLink.size)
|
||||
PhotosEvent.UploadDone(
|
||||
duration = clock().value - uploadCreationDateTime.value,
|
||||
sizeKB = size.toKiB(),
|
||||
reason = when (event.reason) {
|
||||
null -> PhotosEvent.Reason.COMPLETED
|
||||
Reason.ERROR_OTHER -> PhotosEvent.Reason.FAILED_OTHER
|
||||
Reason.ERROR_PERMISSIONS -> PhotosEvent.Reason.FAILED_PERMISSIONS
|
||||
Reason.ERROR_DRIVE_STORAGE -> PhotosEvent.Reason.FAILED_DRIVE_STORAGE
|
||||
Reason.ERROR_LOCAL_STORAGE -> PhotosEvent.Reason.FAILED_LOCAL_STORAGE
|
||||
Reason.ERROR_NOT_ALLOWED -> PhotosEvent.Reason.FAILED_NOT_ALLOWED
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private fun Bytes.toKiB() = value / 1024
|
||||
|
||||
private suspend fun UploadFileLink.isPhoto() = getShare(shareId, flowOf(false))
|
||||
.filterSuccessOrError()
|
||||
.toResult()
|
||||
.getOrThrow()
|
||||
.let { share ->
|
||||
share.type == Share.Type.PHOTO
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
val finalState = listOf(
|
||||
Event.Upload.UploadState.UPLOAD_COMPLETE,
|
||||
Event.Upload.UploadState.UPLOAD_FAILED,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,6 @@ import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.flowWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.accompanist.insets.ProvideWindowInsets
|
||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
@@ -90,6 +89,8 @@ import me.proton.core.drive.base.domain.usecase.ListenToBroadcastMessages
|
||||
import me.proton.core.drive.messagequeue.domain.ActionProvider
|
||||
import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
|
||||
import me.proton.core.drive.thumbnail.presentation.coil.ThumbnailEnabled
|
||||
import me.proton.core.notification.presentation.deeplink.DeeplinkManager
|
||||
import me.proton.core.notification.presentation.deeplink.onActivityCreate
|
||||
import me.proton.core.util.kotlin.CoreLogger
|
||||
import me.proton.drive.android.settings.domain.entity.ThemeStyle
|
||||
import me.proton.drive.android.settings.domain.usecase.GetThemeStyle
|
||||
@@ -106,6 +107,8 @@ class MainActivity : FragmentActivity() {
|
||||
@Inject lateinit var processIntent: ProcessIntent
|
||||
@Inject lateinit var biometricPromptProvider: BiometricPromptProvider
|
||||
@Inject lateinit var appLockManager: AppLockManager
|
||||
@Inject lateinit var deeplinkManager: DeeplinkManager
|
||||
|
||||
lateinit var configurationProvider: ConfigurationProvider
|
||||
private val accountViewModel: AccountViewModel by viewModels()
|
||||
private val bugReportViewModel: BugReportViewModel by viewModels()
|
||||
@@ -128,6 +131,7 @@ class MainActivity : FragmentActivity() {
|
||||
applySecureFlag()
|
||||
setTheme(CorePresentation.style.ProtonTheme_Drive)
|
||||
super.onCreate(savedInstanceState)
|
||||
deeplinkManager.onActivityCreate(this, savedInstanceState)
|
||||
biometricPromptProvider.bindToActivity(this)
|
||||
initializeViewModels()
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
@@ -236,10 +240,8 @@ class MainActivity : FragmentActivity() {
|
||||
private fun Content(isDarkTheme: Boolean, content: @Composable () -> Unit) {
|
||||
ThumbnailEnabled {
|
||||
ProtonTheme(isDarkTheme) {
|
||||
ProvideWindowInsets {
|
||||
ProvideLocalSnackbarPadding {
|
||||
content()
|
||||
}
|
||||
ProvideLocalSnackbarPadding {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.ui.action
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.material.CircularProgressIndicator
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import me.proton.android.drive.ui.viewmodel.PhotosExportDataViewModel
|
||||
import me.proton.core.compose.component.ProtonRawListItem
|
||||
import me.proton.core.compose.flow.rememberFlowWithLifecycle
|
||||
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.drive.i18n.R
|
||||
|
||||
@Composable
|
||||
fun PhotoExtractDataAction(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val viewModel = hiltViewModel<PhotosExportDataViewModel>()
|
||||
val viewState by rememberFlowWithLifecycle(flow = viewModel.viewState)
|
||||
.collectAsState(initial = viewModel.initialViewState)
|
||||
val viewEvent = remember {
|
||||
viewModel.viewEvent()
|
||||
}
|
||||
if (viewState.isExportDataEnabled) {
|
||||
PhotoExtractDataAction(
|
||||
modifier = modifier,
|
||||
onExportData = viewEvent.onExportData,
|
||||
isLoading = viewState.isExportDataLoading,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PhotoExtractDataAction(
|
||||
onExportData: (Context) -> Unit,
|
||||
isLoading: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val localContext = LocalContext.current
|
||||
ProtonRawListItem(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.sizeIn(minHeight = ProtonDimens.ListItemHeight)
|
||||
.clickable {
|
||||
onExportData(localContext)
|
||||
}
|
||||
.padding(horizontal = ProtonDimens.DefaultSpacing),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.photos_export_data),
|
||||
style = ProtonTheme.typography.defaultNorm,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(ProtonDimens.DefaultIconSize),
|
||||
strokeWidth = 1.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PhotoExtractDataActionPreview() {
|
||||
ProtonTheme {
|
||||
PhotoExtractDataAction(
|
||||
onExportData = {},
|
||||
isLoading = true
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ fun ConfirmDeletionDialog(
|
||||
) {
|
||||
val viewModel = hiltViewModel<ConfirmDeletionDialogViewModel>()
|
||||
val viewEvent = remember(viewModel, onDismiss) { viewModel.viewEvent(onDismiss) }
|
||||
val flow = remember(viewModel, onDismiss) { viewModel.viewState(onDismiss) }
|
||||
val flow = remember(viewModel) { viewModel.viewState }
|
||||
val viewState by rememberFlowWithLifecycle(flow).collectAsState(initial = viewModel.initialViewState)
|
||||
|
||||
val name = viewState.name ?: return
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.ui.dialog
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import me.proton.android.drive.photos.presentation.component.ConfirmSkipIssuesDialogContent
|
||||
import me.proton.android.drive.ui.viewmodel.ConfirmSkipIssuesDialogViewModel
|
||||
|
||||
@Composable
|
||||
@ExperimentalCoroutinesApi
|
||||
fun ConfirmSkipIssuesDialog(
|
||||
modifier: Modifier = Modifier,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
) {
|
||||
val viewModel = hiltViewModel<ConfirmSkipIssuesDialogViewModel>()
|
||||
val viewEvent = remember(viewModel, onConfirm) { viewModel.viewEvent(onConfirm) }
|
||||
|
||||
ConfirmSkipIssuesDialogContent(
|
||||
modifier = modifier,
|
||||
onDismiss = onDismiss,
|
||||
onConfirm = viewEvent.onConfirm
|
||||
)
|
||||
}
|
||||
@@ -21,40 +21,48 @@
|
||||
package me.proton.android.drive.ui.navigation
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.animation.AnimatedContentScope
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NamedNavArgument
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.dialog
|
||||
import androidx.navigation.navArgument
|
||||
import androidx.navigation.navDeepLink
|
||||
import com.google.accompanist.navigation.animation.composable
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import me.proton.android.drive.extension.get
|
||||
import me.proton.android.drive.extension.require
|
||||
import me.proton.android.drive.extension.requireArguments
|
||||
import me.proton.android.drive.extension.requireSerializable
|
||||
import me.proton.android.drive.extension.runFromRoute
|
||||
import me.proton.android.drive.lock.presentation.component.AppLock
|
||||
import me.proton.android.drive.log.DriveLogTag
|
||||
import me.proton.android.drive.photos.presentation.component.PhotosPermissionRationale
|
||||
import me.proton.android.drive.ui.dialog.AutoLockDurations
|
||||
import me.proton.android.drive.ui.dialog.ConfirmDeletionDialog
|
||||
import me.proton.android.drive.ui.dialog.ConfirmEmptyTrashDialog
|
||||
import me.proton.android.drive.ui.dialog.ConfirmSkipIssuesDialog
|
||||
import me.proton.android.drive.ui.dialog.ConfirmStopSharingDialog
|
||||
import me.proton.android.drive.ui.dialog.FileOrFolderOptions
|
||||
import me.proton.android.drive.ui.dialog.MultipleFileOrFolderOptions
|
||||
@@ -64,18 +72,20 @@ import me.proton.android.drive.ui.dialog.SortingList
|
||||
import me.proton.android.drive.ui.dialog.SystemAccessDialog
|
||||
import me.proton.android.drive.ui.navigation.animation.defaultEnterSlideTransition
|
||||
import me.proton.android.drive.ui.navigation.animation.defaultPopExitSlideTransition
|
||||
import me.proton.android.drive.ui.navigation.animation.slideComposable
|
||||
import me.proton.android.drive.ui.navigation.internal.AnimatedNavHost
|
||||
import me.proton.android.drive.ui.navigation.internal.DriveNavHost
|
||||
import me.proton.android.drive.ui.navigation.internal.MutableNavControllerSaver
|
||||
import me.proton.android.drive.ui.navigation.internal.createNavController
|
||||
import me.proton.android.drive.ui.navigation.internal.modalBottomSheet
|
||||
import me.proton.android.drive.ui.navigation.internal.rememberAnimatedNavController
|
||||
import me.proton.android.drive.ui.options.OptionsFilter
|
||||
import me.proton.android.drive.ui.screen.AppAccessScreen
|
||||
import me.proton.android.drive.ui.screen.BackupIssuesScreen
|
||||
import me.proton.android.drive.ui.screen.FileInfoScreen
|
||||
import me.proton.android.drive.ui.screen.HomeScreen
|
||||
import me.proton.android.drive.ui.screen.LauncherScreen
|
||||
import me.proton.android.drive.ui.screen.MoveToFolder
|
||||
import me.proton.android.drive.ui.screen.OfflineScreen
|
||||
import me.proton.android.drive.ui.screen.PhotosBackupScreen
|
||||
import me.proton.android.drive.ui.screen.PreviewScreen
|
||||
import me.proton.android.drive.ui.screen.SettingsScreen
|
||||
import me.proton.android.drive.ui.screen.SigningOutScreen
|
||||
@@ -118,15 +128,23 @@ fun AppNavGraph(
|
||||
var homeNavController by rememberSaveable(saver = MutableNavControllerSaver(localContext, keyStoreCrypto)) {
|
||||
mutableStateOf(createNavController(localContext))
|
||||
}
|
||||
DisposableEffect(navController) {
|
||||
val listener = NavController.OnDestinationChangedListener { controller, _, _ ->
|
||||
val destinations = controller.backQueue.map { entry -> entry.destination.route }.joinToString()
|
||||
CoreLogger.d(DriveLogTag.UI, "Destination changed: $destinations")
|
||||
}
|
||||
navController.addOnDestinationChangedListener(listener)
|
||||
onDispose {
|
||||
navController.removeOnDestinationChangedListener(listener)
|
||||
}
|
||||
LaunchedEffect(navController) {
|
||||
navController
|
||||
.currentBackStack
|
||||
.onEach { backStackEntries ->
|
||||
val destinations = backStackEntries.map { entry -> entry.destination.route }.joinToString()
|
||||
CoreLogger.d(DriveLogTag.UI, "App current back stack: $destinations")
|
||||
}
|
||||
.launchIn(this)
|
||||
}
|
||||
LaunchedEffect(homeNavController) {
|
||||
homeNavController
|
||||
.currentBackStack
|
||||
.onEach { backStackEntries ->
|
||||
val destinations = backStackEntries.map { entry -> entry.destination.route }.joinToString()
|
||||
CoreLogger.d(DriveLogTag.UI, "Home current back stack: $destinations")
|
||||
}
|
||||
.launchIn(this)
|
||||
}
|
||||
LaunchedEffect(clearBackstackTrigger) {
|
||||
clearBackstackTrigger
|
||||
@@ -170,9 +188,10 @@ fun AppNavGraph(
|
||||
navigateToSubscription: () -> Unit,
|
||||
onDrawerStateChanged: (Boolean) -> Unit,
|
||||
) {
|
||||
AnimatedNavHost(
|
||||
navHostController = navController,
|
||||
DriveNavHost(
|
||||
navController = navController,
|
||||
startDestination = Screen.Launcher.route,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
addLauncher(navController)
|
||||
addWelcome(navController)
|
||||
@@ -202,6 +221,17 @@ fun AppNavGraph(
|
||||
navigateToSubscription = navigateToSubscription,
|
||||
onDrawerStateChanged = onDrawerStateChanged,
|
||||
)
|
||||
addHomePhotos(
|
||||
navController = navController,
|
||||
homeNavController = homeNavController,
|
||||
deepLinkBaseUrl = deepLinkBaseUrl,
|
||||
navigateToBugReport = navigateToBugReport,
|
||||
navigateToSubscription = navigateToSubscription,
|
||||
onDrawerStateChanged = onDrawerStateChanged,
|
||||
)
|
||||
addPhotosIssues(navController)
|
||||
addConfirmSkipIssues(navController)
|
||||
addPhotosPermissionRationale(navController)
|
||||
addSortingList()
|
||||
addFileOrFolderOptions(navController)
|
||||
addMultipleFileOrFolderOptions(navController)
|
||||
@@ -225,6 +255,7 @@ fun AppNavGraph(
|
||||
addAppAccess(navController)
|
||||
addSystemAccessDialog(navController)
|
||||
addAutoLockDurations(navController)
|
||||
addPhotosBackup(navController)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,7 +320,12 @@ fun NavGraphBuilder.addSignOutConfirmationDialog(navController: NavHostControlle
|
||||
popUpTo(Screen.Home.route) { inclusive = true }
|
||||
}
|
||||
},
|
||||
onDismiss = { navController.popBackStack() }
|
||||
onDismiss = {
|
||||
navController.popBackStack(
|
||||
route = Screen.Dialogs.SignOut.route,
|
||||
inclusive = true,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -326,6 +362,10 @@ fun NavGraphBuilder.addFileOrFolderOptions(
|
||||
navArgument(Screen.Files.USER_ID) { type = NavType.StringType },
|
||||
navArgument(Screen.FileOrFolderOptions.SHARE_ID) { type = NavType.StringType },
|
||||
navArgument(Screen.FileOrFolderOptions.LINK_ID) { type = NavType.StringType },
|
||||
navArgument(Screen.FileOrFolderOptions.OPTIONS_FILTER) {
|
||||
type = NavType.EnumType(OptionsFilter::class.java)
|
||||
defaultValue = OptionsFilter.FILES
|
||||
},
|
||||
),
|
||||
) { navBackStackEntry, runAction ->
|
||||
val userId = UserId(navBackStackEntry.require(Screen.Files.USER_ID))
|
||||
@@ -366,7 +406,12 @@ fun NavGraphBuilder.addFileOrFolderOptions(
|
||||
popUpTo(Screen.FileOrFolderOptions.route) { inclusive = true }
|
||||
}
|
||||
},
|
||||
dismiss = { navController.popBackStack() }
|
||||
dismiss = {
|
||||
navController.popBackStack(
|
||||
route = Screen.FileOrFolderOptions.route,
|
||||
inclusive = true,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -378,6 +423,10 @@ fun NavGraphBuilder.addMultipleFileOrFolderOptions(
|
||||
arguments = listOf(
|
||||
navArgument(Screen.Files.USER_ID) { type = NavType.StringType },
|
||||
navArgument(Screen.MultipleFileOrFolderOptions.SELECTION_ID) { type = NavType.StringType },
|
||||
navArgument(Screen.MultipleFileOrFolderOptions.OPTIONS_FILTER) {
|
||||
type = NavType.EnumType(OptionsFilter::class.java)
|
||||
defaultValue = OptionsFilter.FILES
|
||||
},
|
||||
),
|
||||
) { navBackStackEntry, runAction ->
|
||||
val userId = UserId(navBackStackEntry.require(Screen.Files.USER_ID))
|
||||
@@ -388,7 +437,12 @@ fun NavGraphBuilder.addMultipleFileOrFolderOptions(
|
||||
popUpTo(Screen.MultipleFileOrFolderOptions.route) { inclusive = true }
|
||||
}
|
||||
},
|
||||
dismiss = { navController.popBackStack() },
|
||||
dismiss = {
|
||||
navController.popBackStack(
|
||||
route = Screen.MultipleFileOrFolderOptions.route,
|
||||
inclusive = true,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -417,7 +471,10 @@ fun NavGraphBuilder.addParentFolderOptions(
|
||||
}
|
||||
},
|
||||
dismiss = {
|
||||
navController.popBackStack()
|
||||
navController.popBackStack(
|
||||
route = Screen.ParentFolderOptions.route,
|
||||
inclusive = true,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -431,7 +488,14 @@ fun NavGraphBuilder.addConfirmDeletionDialog(navController: NavHostController) =
|
||||
navArgument(Screen.Files.Dialogs.ConfirmDeletion.SHARE_ID) { type = NavType.StringType },
|
||||
),
|
||||
) {
|
||||
ConfirmDeletionDialog(onDismiss = { navController.popBackStack() })
|
||||
ConfirmDeletionDialog(
|
||||
onDismiss = {
|
||||
navController.popBackStack(
|
||||
route = Screen.Files.Dialogs.ConfirmDeletion.route,
|
||||
inclusive = true,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
@@ -450,7 +514,12 @@ fun NavGraphBuilder.addConfirmStopSharingDialog(navController: NavHostController
|
||||
),
|
||||
) { navBackStackEntry ->
|
||||
val confirmPopUpRoute = navBackStackEntry.get<String>(Screen.Files.Dialogs.ConfirmStopSharing.CONFIRM_POP_UP_ROUTE)
|
||||
val onDismiss: () -> Unit = { navController.popBackStack() }
|
||||
val onDismiss: () -> Unit = {
|
||||
navController.popBackStack(
|
||||
route = Screen.Files.Dialogs.ConfirmStopSharing.route,
|
||||
inclusive = true,
|
||||
)
|
||||
}
|
||||
val onConfirm: () -> Unit = confirmPopUpRoute?.let {
|
||||
val confirmPopUpRouteInclusive = navBackStackEntry.get<Boolean>(
|
||||
Screen.Files.Dialogs.ConfirmStopSharing.CONFIRM_POP_UP_ROUTE_INCLUSIVE
|
||||
@@ -470,7 +539,14 @@ fun NavGraphBuilder.addConfirmEmptyTrashDialog(navController: NavHostController)
|
||||
navArgument(Screen.Files.USER_ID) { type = NavType.StringType },
|
||||
),
|
||||
) {
|
||||
ConfirmEmptyTrashDialog(onDismiss = { navController.popBackStack() })
|
||||
ConfirmEmptyTrashDialog(
|
||||
onDismiss = {
|
||||
navController.popBackStack(
|
||||
route = Screen.Files.Dialogs.ConfirmEmptyTrash.route,
|
||||
inclusive = true,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
@@ -517,8 +593,8 @@ internal fun NavGraphBuilder.addHome(
|
||||
navigateToOffline = {
|
||||
navController.navigate(Screen.OfflineFiles(userId))
|
||||
},
|
||||
navigateToPreview = { linkId, pagerType ->
|
||||
navController.navigate(Screen.PagerPreview(pagerType, userId, linkId))
|
||||
navigateToPreview = { fileId, pagerType, optionsFilter ->
|
||||
navController.navigate(Screen.PagerPreview(pagerType, userId, fileId, optionsFilter))
|
||||
},
|
||||
navigateToSorting = { sorting ->
|
||||
navController.navigate(
|
||||
@@ -528,14 +604,14 @@ internal fun NavGraphBuilder.addHome(
|
||||
navigateToSettings = {
|
||||
navController.navigate(Screen.Settings(userId))
|
||||
},
|
||||
navigateToFileOrFolderOptions = { linkId ->
|
||||
navigateToFileOrFolderOptions = { linkId, optionsFilter ->
|
||||
navController.navigate(
|
||||
Screen.FileOrFolderOptions(userId, linkId)
|
||||
Screen.FileOrFolderOptions(userId, linkId, optionsFilter)
|
||||
)
|
||||
},
|
||||
navigateToMultipleFileOrFolderOptions = { selectionId ->
|
||||
navigateToMultipleFileOrFolderOptions = { selectionId, optionsFilter ->
|
||||
navController.navigate(
|
||||
Screen.MultipleFileOrFolderOptions(userId, selectionId)
|
||||
Screen.MultipleFileOrFolderOptions(userId, selectionId, optionsFilter)
|
||||
)
|
||||
},
|
||||
navigateToParentFolderOptions = { folderId ->
|
||||
@@ -544,6 +620,17 @@ internal fun NavGraphBuilder.addHome(
|
||||
)
|
||||
},
|
||||
navigateToSubscription = navigateToSubscription,
|
||||
navigateToPhotosIssues = { folderId ->
|
||||
navController.navigate(
|
||||
Screen.BackupIssues.invoke(folderId)
|
||||
)
|
||||
},
|
||||
navigateToPhotosPermissionRationale = {
|
||||
navController.navigate(
|
||||
Screen.PhotosPermissionRationale(userId)
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -620,6 +707,126 @@ fun NavGraphBuilder.addHomeShared(
|
||||
onDrawerStateChanged = onDrawerStateChanged
|
||||
)
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@ExperimentalCoroutinesApi
|
||||
fun NavGraphBuilder.addHomePhotos(
|
||||
navController: NavHostController,
|
||||
homeNavController: NavHostController,
|
||||
deepLinkBaseUrl: String,
|
||||
navigateToBugReport: () -> Unit,
|
||||
navigateToSubscription: () -> Unit,
|
||||
onDrawerStateChanged: (Boolean) -> Unit,
|
||||
) = addHome(
|
||||
navController = navController,
|
||||
homeNavController = homeNavController,
|
||||
deepLinkBaseUrl = deepLinkBaseUrl,
|
||||
route = Screen.Photos.route,
|
||||
startDestination = Screen.Photos.route,
|
||||
navigateToBugReport = navigateToBugReport,
|
||||
navigateToSubscription = navigateToSubscription,
|
||||
onDrawerStateChanged = onDrawerStateChanged
|
||||
)
|
||||
|
||||
fun NavGraphBuilder.addPhotosPermissionRationale(
|
||||
navController: NavHostController,
|
||||
) = composable(
|
||||
route = Screen.PhotosPermissionRationale.route,
|
||||
arguments = listOf(
|
||||
navArgument(Screen.Files.USER_ID) { type = NavType.StringType },
|
||||
),
|
||||
) {
|
||||
PhotosPermissionRationale(
|
||||
modifier = Modifier
|
||||
.navigationBarsPadding()
|
||||
.offset(y = (-8).dp), // remove default modalBottomSheet top space
|
||||
// me.proton.core.compose.component.bottomsheet.ModalBottomSheet:59
|
||||
onBack = {
|
||||
navController.popBackStack(
|
||||
route = Screen.PhotosPermissionRationale.route,
|
||||
inclusive = true,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
fun NavGraphBuilder.addPhotosIssues(
|
||||
navController: NavHostController,
|
||||
) = composable(
|
||||
route = Screen.BackupIssues.route,
|
||||
arguments = listOf(
|
||||
navArgument(Screen.BackupIssues.USER_ID) { type = NavType.StringType },
|
||||
navArgument(Screen.BackupIssues.SHARE_ID) { type = NavType.StringType },
|
||||
navArgument(Screen.BackupIssues.FOLDER_ID) { type = NavType.StringType },
|
||||
),
|
||||
) { navBackStackEntry ->
|
||||
val userId = UserId(navBackStackEntry.require(Screen.BackupIssues.USER_ID))
|
||||
val shareId = ShareId(userId, navBackStackEntry.require(Screen.BackupIssues.SHARE_ID))
|
||||
val folderId = FolderId(shareId, navBackStackEntry.require(Screen.BackupIssues.FOLDER_ID))
|
||||
BackupIssuesScreen(
|
||||
modifier = Modifier
|
||||
.navigationBarsPadding(),
|
||||
navigateBack = {
|
||||
navController.popBackStack(
|
||||
route = Screen.BackupIssues.route,
|
||||
inclusive = true,
|
||||
)
|
||||
},
|
||||
navigateToSkipIssues = {
|
||||
navController.navigate(
|
||||
Screen.BackupIssues.Dialogs.ConfirmSkipIssues(
|
||||
folderId,
|
||||
Screen.BackupIssues.route
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.addConfirmSkipIssues(
|
||||
navController: NavHostController,
|
||||
) = dialog(
|
||||
route = Screen.BackupIssues.Dialogs.ConfirmSkipIssues.route,
|
||||
arguments = listOf(
|
||||
navArgument(Screen.BackupIssues.USER_ID) { type = NavType.StringType },
|
||||
navArgument(Screen.BackupIssues.SHARE_ID) { type = NavType.StringType },
|
||||
navArgument(Screen.BackupIssues.FOLDER_ID) { type = NavType.StringType },
|
||||
navArgument(Screen.BackupIssues.Dialogs.ConfirmSkipIssues.CONFIRM_POP_UP_ROUTE) {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
},
|
||||
navArgument(Screen.BackupIssues.Dialogs.ConfirmSkipIssues.CONFIRM_POP_UP_ROUTE_INCLUSIVE) {
|
||||
type =
|
||||
NavType.BoolType
|
||||
},
|
||||
),
|
||||
) { navBackStackEntry ->
|
||||
val confirmPopUpRoute =
|
||||
navBackStackEntry.get<String>(Screen.BackupIssues.Dialogs.ConfirmSkipIssues.CONFIRM_POP_UP_ROUTE)
|
||||
val onDismiss: () -> Unit = {
|
||||
navController.popBackStack(
|
||||
route = Screen.BackupIssues.Dialogs.ConfirmSkipIssues.route,
|
||||
inclusive = true,
|
||||
)
|
||||
}
|
||||
val onConfirm: () -> Unit = confirmPopUpRoute?.let {
|
||||
val confirmPopUpRouteInclusive = navBackStackEntry.get<Boolean>(
|
||||
Screen.BackupIssues.Dialogs.ConfirmSkipIssues.CONFIRM_POP_UP_ROUTE_INCLUSIVE
|
||||
) ?: true
|
||||
{
|
||||
navController.popBackStack(
|
||||
route = confirmPopUpRoute,
|
||||
inclusive = confirmPopUpRouteInclusive
|
||||
)
|
||||
}
|
||||
} ?: onDismiss
|
||||
ConfirmSkipIssuesDialog(
|
||||
modifier = Modifier
|
||||
.navigationBarsPadding(),
|
||||
onDismiss = onDismiss,
|
||||
onConfirm = onConfirm,
|
||||
)
|
||||
}
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
@ExperimentalAnimationApi
|
||||
fun NavGraphBuilder.addTrash(navController: NavHostController) = composable(
|
||||
@@ -630,7 +837,12 @@ fun NavGraphBuilder.addTrash(navController: NavHostController) = composable(
|
||||
) { navBackStackEntry ->
|
||||
val userId = UserId(navBackStackEntry.require(Screen.Files.USER_ID))
|
||||
TrashScreen(
|
||||
navigateBack = { navController.popBackStack() },
|
||||
navigateBack = {
|
||||
navController.popBackStack(
|
||||
route = Screen.Trash.route,
|
||||
inclusive = true,
|
||||
)
|
||||
},
|
||||
navigateToEmptyTrash = {
|
||||
navController.navigate(Screen.Files.Dialogs.ConfirmEmptyTrash(userId))
|
||||
},
|
||||
@@ -645,7 +857,7 @@ fun NavGraphBuilder.addTrash(navController: NavHostController) = composable(
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
@ExperimentalAnimationApi
|
||||
fun NavGraphBuilder.addOffline(navController: NavHostController) = slideComposable(
|
||||
fun NavGraphBuilder.addOffline(navController: NavHostController) = composable(
|
||||
route = Screen.OfflineFiles.route,
|
||||
arguments = listOf(
|
||||
navArgument(Screen.Files.USER_ID) { type = NavType.StringType },
|
||||
@@ -673,7 +885,12 @@ fun NavGraphBuilder.addOffline(navController: NavHostController) = slideComposab
|
||||
Screen.PagerPreview(pagerType, userId, fileId)
|
||||
)
|
||||
},
|
||||
navigateBack = { navController.popBackStack() },
|
||||
navigateBack = {
|
||||
navController.popBackStack(
|
||||
route = Screen.OfflineFiles.route,
|
||||
inclusive = true,
|
||||
)
|
||||
},
|
||||
navigateToSortingDialog = { sorting ->
|
||||
navController.navigate(
|
||||
Screen.Sorting(userId, null, sorting.by, sorting.direction)
|
||||
@@ -689,7 +906,7 @@ fun NavGraphBuilder.addOffline(navController: NavHostController) = slideComposab
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
@ExperimentalAnimationApi
|
||||
fun NavGraphBuilder.addPagerPreview(navController: NavHostController) = slideComposable(
|
||||
fun NavGraphBuilder.addPagerPreview(navController: NavHostController) = composable(
|
||||
route = Screen.PagerPreview.route,
|
||||
enterTransition = defaultEnterSlideTransition { true },
|
||||
exitTransition = { ExitTransition.None },
|
||||
@@ -699,14 +916,24 @@ fun NavGraphBuilder.addPagerPreview(navController: NavHostController) = slideCom
|
||||
navArgument(Screen.PagerPreview.PAGER_TYPE) { type = NavType.EnumType(PagerType::class.java) },
|
||||
navArgument(Screen.PagerPreview.USER_ID) { type = NavType.StringType },
|
||||
navArgument(Screen.PagerPreview.FILE_ID) { type = NavType.StringType },
|
||||
navArgument(Screen.PagerPreview.OPTIONS_FILTER) {
|
||||
type = NavType.EnumType(OptionsFilter::class.java)
|
||||
defaultValue = OptionsFilter.FILES
|
||||
},
|
||||
),
|
||||
) { navBackStackEntry ->
|
||||
val userId = UserId(navBackStackEntry.require(Screen.Files.USER_ID))
|
||||
val optionsFilter = navBackStackEntry.requireSerializable(Screen.PagerPreview.OPTIONS_FILTER, OptionsFilter::class.java)
|
||||
PreviewScreen(
|
||||
navigateBack = { navController.popBackStack() },
|
||||
navigateBack = {
|
||||
navController.popBackStack(
|
||||
route = Screen.PagerPreview.route,
|
||||
inclusive = true,
|
||||
)
|
||||
},
|
||||
navigateToFileOrFolderOptions = { linkId ->
|
||||
navController.navigate(
|
||||
Screen.FileOrFolderOptions(userId, linkId)
|
||||
Screen.FileOrFolderOptions(userId, linkId, optionsFilter)
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -721,13 +948,21 @@ fun NavGraphBuilder.addSettings(navController: NavHostController) = composable(
|
||||
) { navBackStackEntry ->
|
||||
val userId = UserId(navBackStackEntry.require(Screen.Files.USER_ID))
|
||||
SettingsScreen(
|
||||
navigateBack = { navController.popBackStack() },
|
||||
navigateBack = {
|
||||
navController.popBackStack(
|
||||
route = Screen.Settings.route,
|
||||
inclusive = true,
|
||||
)
|
||||
},
|
||||
navigateToAppAccess = {
|
||||
navController.navigate(Screen.Settings.AppAccess(userId))
|
||||
},
|
||||
navigateToAutoLockDurations = {
|
||||
navController.navigate(Screen.Settings.AutoLockDurations(userId))
|
||||
},
|
||||
navigateToPhotosBackup = {
|
||||
navController.navigate(Screen.Settings.PhotosBackup(userId))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -735,17 +970,22 @@ fun NavGraphBuilder.addSettings(navController: NavHostController) = composable(
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun NavGraphBuilder.addFileInfo(navController: NavHostController) = composable(
|
||||
route = Screen.Info.route,
|
||||
enterTransition = defaultEnterSlideTransition(towards = AnimatedContentScope.SlideDirection.Up) { true },
|
||||
enterTransition = defaultEnterSlideTransition(towards = AnimatedContentTransitionScope.SlideDirection.Up) { true },
|
||||
exitTransition = { ExitTransition.None },
|
||||
popEnterTransition = { EnterTransition.None },
|
||||
popExitTransition = defaultPopExitSlideTransition(towards = AnimatedContentScope.SlideDirection.Down) { true },
|
||||
popExitTransition = defaultPopExitSlideTransition(towards = AnimatedContentTransitionScope.SlideDirection.Down) { true },
|
||||
arguments = listOf(
|
||||
navArgument(Screen.Info.USER_ID) { type = NavType.StringType },
|
||||
navArgument(Screen.Info.LINK_ID) { type = NavType.StringType },
|
||||
),
|
||||
) {
|
||||
FileInfoScreen(
|
||||
navigateBack = { navController.popBackStack() },
|
||||
navigateBack = {
|
||||
navController.popBackStack(
|
||||
route = Screen.Info.route,
|
||||
inclusive = true,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -766,7 +1006,14 @@ fun NavGraphBuilder.addRenameDialog(navController: NavHostController) = dialog(
|
||||
},
|
||||
),
|
||||
) {
|
||||
Rename(onDismiss = { navController.popBackStack() })
|
||||
Rename(
|
||||
onDismiss = {
|
||||
navController.popBackStack(
|
||||
route = Screen.Files.Dialogs.Rename.route,
|
||||
inclusive = true,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
@@ -778,7 +1025,14 @@ fun NavGraphBuilder.addCreateFolderDialog(navController: NavHostController) = di
|
||||
navArgument(Screen.Files.Dialogs.CreateFolder.PARENT_ID) { type = NavType.StringType },
|
||||
),
|
||||
) {
|
||||
CreateFolder(onDismiss = { navController.popBackStack() })
|
||||
CreateFolder(
|
||||
onDismiss = {
|
||||
navController.popBackStack(
|
||||
route = Screen.Files.Dialogs.CreateFolder.route,
|
||||
inclusive = true,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
@@ -820,7 +1074,10 @@ fun NavGraphBuilder.addMoveToFolder(navController: NavHostController) = composab
|
||||
navController.navigate(Screen.Files.Dialogs.CreateFolder(userId, parentId))
|
||||
}
|
||||
) {
|
||||
navController.popBackStack()
|
||||
navController.popBackStack(
|
||||
route = Screen.Move.route,
|
||||
inclusive = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -831,7 +1088,10 @@ fun NavGraphBuilder.addStorageFull(navController: NavHostController, deepLinkBas
|
||||
)
|
||||
) {
|
||||
StorageFullDialog {
|
||||
navController.popBackStack()
|
||||
navController.popBackStack(
|
||||
route = Screen.Dialogs.StorageFull.route,
|
||||
inclusive = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -844,16 +1104,21 @@ fun NavGraphBuilder.addSendFile(navController: NavHostController) = dialog(
|
||||
navArgument(Screen.SendFile.FILE_ID) { type = NavType.StringType },
|
||||
),
|
||||
) {
|
||||
SendFileDialog { navController.popBackStack() }
|
||||
SendFileDialog {
|
||||
navController.popBackStack(
|
||||
route = Screen.SendFile.route,
|
||||
inclusive = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
fun NavGraphBuilder.addShareViaLink(navController: NavHostController) = composable(
|
||||
route = Screen.ShareViaLink.route,
|
||||
enterTransition = defaultEnterSlideTransition(towards = AnimatedContentScope.SlideDirection.Up) { true },
|
||||
enterTransition = defaultEnterSlideTransition(towards = AnimatedContentTransitionScope.SlideDirection.Up) { true },
|
||||
exitTransition = { ExitTransition.None },
|
||||
popEnterTransition = { EnterTransition.None },
|
||||
popExitTransition = defaultPopExitSlideTransition(towards = AnimatedContentScope.SlideDirection.Down) { true },
|
||||
popExitTransition = defaultPopExitSlideTransition(towards = AnimatedContentTransitionScope.SlideDirection.Down) { true },
|
||||
arguments = listOf(
|
||||
navArgument(Screen.ShareViaLink.USER_ID) { type = NavType.StringType },
|
||||
navArgument(Screen.ShareViaLink.LINK_ID) { type = NavType.StringType },
|
||||
@@ -867,7 +1132,12 @@ fun NavGraphBuilder.addShareViaLink(navController: NavHostController) = composab
|
||||
navigateToDiscardChanges = { linkId ->
|
||||
navController.navigate(Screen.ShareViaLink.Dialogs.DiscardChanges(userId, linkId))
|
||||
},
|
||||
navigateBack = { navController.popBackStack() },
|
||||
navigateBack = {
|
||||
navController.popBackStack(
|
||||
route = Screen.ShareViaLink.route,
|
||||
inclusive = true,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -880,8 +1150,18 @@ fun NavGraphBuilder.addDiscardShareViaLinkChanges(navController: NavHostControll
|
||||
),
|
||||
) {
|
||||
DiscardChangesDialog(
|
||||
onDismiss = { navController.popBackStack() },
|
||||
onConfirm = { navController.popBackStack(route = Screen.ShareViaLink.route, inclusive = true) }
|
||||
onDismiss = {
|
||||
navController.popBackStack(
|
||||
route = Screen.ShareViaLink.Dialogs.DiscardChanges.route,
|
||||
inclusive = true,
|
||||
)
|
||||
},
|
||||
onConfirm = {
|
||||
navController.popBackStack(
|
||||
route = Screen.ShareViaLink.route,
|
||||
inclusive = true,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -942,7 +1222,10 @@ fun NavGraphBuilder.addAppAccess(navController: NavHostController) = composable(
|
||||
navController.navigate(Screen.Settings.AppAccess.Dialogs.SystemAccess(userId))
|
||||
},
|
||||
navigateBack = {
|
||||
navController.popBackStack()
|
||||
navController.popBackStack(
|
||||
route = Screen.Settings.AppAccess.route,
|
||||
inclusive = true,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -954,7 +1237,14 @@ fun NavGraphBuilder.addSystemAccessDialog(navController: NavHostController) = di
|
||||
navArgument(Screen.Settings.USER_ID) { type = NavType.StringType },
|
||||
),
|
||||
) {
|
||||
SystemAccessDialog(onDismiss = { navController.popBackStack() })
|
||||
SystemAccessDialog(
|
||||
onDismiss = {
|
||||
navController.popBackStack(
|
||||
route = Screen.Settings.AppAccess.Dialogs.SystemAccess.route,
|
||||
inclusive = true,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.addAutoLockDurations(
|
||||
@@ -965,6 +1255,38 @@ fun NavGraphBuilder.addAutoLockDurations(
|
||||
) { _, runAction ->
|
||||
AutoLockDurations(
|
||||
runAction = runAction,
|
||||
dismiss = { navController.popBackStack() }
|
||||
dismiss = {
|
||||
navController.popBackStack(
|
||||
route = Screen.Settings.AutoLockDurations.route,
|
||||
inclusive = true,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
fun NavGraphBuilder.addPhotosBackup(navController: NavHostController) = composable(
|
||||
route = Screen.Settings.PhotosBackup.route,
|
||||
enterTransition = defaultEnterSlideTransition { true },
|
||||
exitTransition = { ExitTransition.None },
|
||||
popEnterTransition = { EnterTransition.None },
|
||||
popExitTransition = defaultPopExitSlideTransition { true },
|
||||
arguments = listOf(
|
||||
navArgument(Screen.Settings.USER_ID) { type = NavType.StringType },
|
||||
),
|
||||
) { navBackStackEntry ->
|
||||
val userId = UserId(navBackStackEntry.require(Screen.Settings.USER_ID))
|
||||
PhotosBackupScreen(
|
||||
navigateToPhotosPermissionRationale = {
|
||||
navController.navigate(
|
||||
Screen.PhotosPermissionRationale(userId)
|
||||
)
|
||||
},
|
||||
navigateBack = {
|
||||
navController.popBackStack(
|
||||
route = Screen.Settings.PhotosBackup.route,
|
||||
inclusive = true,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,12 +21,13 @@ package me.proton.android.drive.ui.navigation
|
||||
import android.os.Bundle
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navArgument
|
||||
import androidx.navigation.navDeepLink
|
||||
import com.google.accompanist.navigation.animation.composable
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import me.proton.android.drive.extension.get
|
||||
import me.proton.android.drive.extension.require
|
||||
@@ -34,9 +35,10 @@ import me.proton.android.drive.ui.navigation.animation.defaultEnterSlideTransiti
|
||||
import me.proton.android.drive.ui.navigation.animation.defaultExitSlideTransition
|
||||
import me.proton.android.drive.ui.navigation.animation.defaultPopEnterSlideTransition
|
||||
import me.proton.android.drive.ui.navigation.animation.defaultPopExitSlideTransition
|
||||
import me.proton.android.drive.ui.navigation.animation.slideComposable
|
||||
import me.proton.android.drive.ui.navigation.internal.AnimatedNavHost
|
||||
import me.proton.android.drive.ui.navigation.internal.DriveNavHost
|
||||
import me.proton.android.drive.ui.options.OptionsFilter
|
||||
import me.proton.android.drive.ui.screen.FilesScreen
|
||||
import me.proton.android.drive.ui.screen.PhotosScreen
|
||||
import me.proton.android.drive.ui.screen.SharedScreen
|
||||
import me.proton.android.drive.ui.viewstate.HomeScaffoldState
|
||||
import me.proton.core.domain.entity.UserId
|
||||
@@ -44,6 +46,7 @@ import me.proton.core.drive.link.domain.entity.FileId
|
||||
import me.proton.core.drive.link.domain.entity.FolderId
|
||||
import me.proton.core.drive.link.domain.entity.LinkId
|
||||
import me.proton.core.drive.link.selection.domain.entity.SelectionId
|
||||
import me.proton.core.drive.share.domain.entity.ShareId
|
||||
import me.proton.core.drive.sorting.domain.entity.Sorting
|
||||
|
||||
@Composable
|
||||
@@ -55,13 +58,16 @@ fun HomeNavGraph(
|
||||
arguments: Bundle,
|
||||
startDestination: String,
|
||||
homeScaffoldState: HomeScaffoldState,
|
||||
navigateToPreview: (fileId: FileId, pagerType: PagerType) -> Unit,
|
||||
navigateToPreview: (fileId: FileId, pagerType: PagerType, optionsFilter: OptionsFilter) -> Unit,
|
||||
navigateToSorting: (sorting: Sorting) -> Unit,
|
||||
navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit,
|
||||
navigateToMultipleFileOrFolderOptions: (SelectionId) -> Unit,
|
||||
navigateToFileOrFolderOptions: (linkId: LinkId, optionsFilter: OptionsFilter) -> Unit,
|
||||
navigateToMultipleFileOrFolderOptions: (selectionId: SelectionId, optionsFilter: OptionsFilter) -> Unit,
|
||||
navigateToParentFolderOptions: (folderId: FolderId) -> Unit,
|
||||
) = AnimatedNavHost(
|
||||
navHostController = homeNavController,
|
||||
navigateToPhotosPermissionRationale: () -> Unit,
|
||||
navigateToSubscription: () -> Unit,
|
||||
navigateToPhotosIssues: (FolderId) -> Unit,
|
||||
) = DriveNavHost(
|
||||
navController = homeNavController,
|
||||
startDestination = startDestination
|
||||
) {
|
||||
addFiles(
|
||||
@@ -69,18 +75,29 @@ fun HomeNavGraph(
|
||||
deepLinkBaseUrl,
|
||||
arguments,
|
||||
homeScaffoldState,
|
||||
{ fileId -> navigateToPreview(fileId, PagerType.FOLDER) },
|
||||
{ fileId -> navigateToPreview(fileId, PagerType.FOLDER, OptionsFilter.FILES) },
|
||||
navigateToSorting,
|
||||
navigateToFileOrFolderOptions,
|
||||
navigateToMultipleFileOrFolderOptions,
|
||||
{ linkId -> navigateToFileOrFolderOptions(linkId, OptionsFilter.FILES) },
|
||||
{ selectionId -> navigateToMultipleFileOrFolderOptions(selectionId, OptionsFilter.FILES) },
|
||||
navigateToParentFolderOptions,
|
||||
)
|
||||
addShared(
|
||||
homeScaffoldState,
|
||||
homeNavController,
|
||||
{ fileId -> navigateToPreview(fileId, PagerType.SINGLE) },
|
||||
{ fileId -> navigateToPreview(fileId, PagerType.SINGLE, OptionsFilter.FILES) },
|
||||
navigateToSorting,
|
||||
navigateToFileOrFolderOptions,
|
||||
{ linkId -> navigateToFileOrFolderOptions(linkId, OptionsFilter.FILES) },
|
||||
)
|
||||
addPhotos(
|
||||
homeScaffoldState,
|
||||
navigateToPhotosPermissionRationale,
|
||||
navigateToPhotosPreview = { fileId -> navigateToPreview(fileId, PagerType.PHOTO, OptionsFilter.PHOTOS) },
|
||||
navigateToPhotosOptions = { fileId -> navigateToFileOrFolderOptions(fileId, OptionsFilter.PHOTOS) },
|
||||
navigateToMultiplePhotosOptions = { selectionId ->
|
||||
navigateToMultipleFileOrFolderOptions(selectionId, OptionsFilter.PHOTOS)
|
||||
},
|
||||
navigateToSubscription = navigateToSubscription,
|
||||
navigateToPhotosIssues = navigateToPhotosIssues,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -96,7 +113,7 @@ fun NavGraphBuilder.addFiles(
|
||||
navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit,
|
||||
navigateToMultipleFileOrFolderOptions: (SelectionId) -> Unit,
|
||||
navigateToParentFolderOptions: (folderId: FolderId) -> Unit,
|
||||
) = slideComposable(
|
||||
) = composable(
|
||||
route = Screen.Files.route,
|
||||
enterTransition = defaultEnterSlideTransition {
|
||||
targetState.get<String>(Screen.Files.FOLDER_ID) != null &&
|
||||
@@ -129,24 +146,34 @@ fun NavGraphBuilder.addFiles(
|
||||
navDeepLink { uriPattern = Screen.Files.deepLink(deepLinkBaseUrl) }
|
||||
)
|
||||
) { navBackStackEntry ->
|
||||
val argUserId = UserId(navBackStackEntry.require(Screen.Files.USER_ID, arguments))
|
||||
val argShareId = navBackStackEntry.get<String>(Screen.Files.SHARE_ID, arguments)
|
||||
val argFolderId = navBackStackEntry.get<String>(Screen.Files.FOLDER_ID, arguments)
|
||||
navBackStackEntry.arguments?.putString(Screen.Files.USER_ID, argUserId.id)
|
||||
navBackStackEntry.arguments?.putString(Screen.Files.SHARE_ID, argShareId)
|
||||
navBackStackEntry.arguments?.putString(Screen.Files.FOLDER_ID, argFolderId)
|
||||
FilesScreen(
|
||||
homeScaffoldState,
|
||||
navigateToFiles = { folderId, folderName ->
|
||||
navController.navigate(Screen.Files(argUserId, folderId, folderName))
|
||||
},
|
||||
navigateToPreview = navigateToPreview,
|
||||
navigateToSortingDialog = navigateToSorting,
|
||||
navigateBack = { navController.popBackStack() },
|
||||
navigateToFileOrFolderOptions = navigateToFileOrFolderOptions,
|
||||
navigateToMultipleFileOrFolderOptions = navigateToMultipleFileOrFolderOptions,
|
||||
navigateToParentFolderOptions = navigateToParentFolderOptions,
|
||||
)
|
||||
navBackStackEntry.get<String>(Screen.Files.USER_ID)?.let { userId ->
|
||||
FilesScreen(
|
||||
homeScaffoldState,
|
||||
navigateToFiles = { folderId, folderName ->
|
||||
navController.navigate(Screen.Files(UserId(userId), folderId, folderName))
|
||||
},
|
||||
navigateToPreview = navigateToPreview,
|
||||
navigateToSortingDialog = navigateToSorting,
|
||||
navigateBack = { navController.popBackStack() },
|
||||
navigateToFileOrFolderOptions = navigateToFileOrFolderOptions,
|
||||
navigateToMultipleFileOrFolderOptions = navigateToMultipleFileOrFolderOptions,
|
||||
navigateToParentFolderOptions = navigateToParentFolderOptions,
|
||||
)
|
||||
} ?: let {
|
||||
val userId = UserId(requireNotNull(arguments.getString(Screen.Files.USER_ID)))
|
||||
val folderId = arguments.getString(Screen.Files.SHARE_ID)?.let { shareId ->
|
||||
arguments.getString(Screen.Files.FOLDER_ID)?.let { folderId ->
|
||||
FolderId(ShareId(userId, shareId), folderId)
|
||||
}
|
||||
}
|
||||
val folderName = arguments.getString(Screen.Files.FOLDER_NAME)
|
||||
navController.navigate(Screen.Files(userId, folderId, folderName)) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
inclusive = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@@ -178,3 +205,35 @@ fun NavGraphBuilder.addShared(
|
||||
navigateToFileOrFolderOptions = navigateToFileOrFolderOptions,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
fun NavGraphBuilder.addPhotos(
|
||||
homeScaffoldState: HomeScaffoldState,
|
||||
navigateToPhotosPermissionRationale: () -> Unit,
|
||||
navigateToPhotosPreview: (fileId: FileId) -> Unit,
|
||||
navigateToPhotosOptions: (fileId: FileId) -> Unit,
|
||||
navigateToMultiplePhotosOptions: (selectionId: SelectionId) -> Unit,
|
||||
navigateToSubscription: () -> Unit,
|
||||
navigateToPhotosIssues: (FolderId) -> Unit,
|
||||
) = composable(
|
||||
route = Screen.Photos.route,
|
||||
arguments = listOf(
|
||||
navArgument(Screen.Photos.USER_ID) { type = NavType.StringType },
|
||||
navArgument(Screen.Photos.SHARE_ID) {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
},
|
||||
)
|
||||
) {
|
||||
PhotosScreen(
|
||||
homeScaffoldState = homeScaffoldState,
|
||||
navigateToPhotosPermissionRationale = navigateToPhotosPermissionRationale,
|
||||
navigateToPhotosPreview = navigateToPhotosPreview,
|
||||
navigateToPhotosOptions = navigateToPhotosOptions,
|
||||
navigateToMultiplePhotosOptions = navigateToMultiplePhotosOptions,
|
||||
navigateToSubscription = navigateToSubscription,
|
||||
navigateToPhotosIssues = navigateToPhotosIssues,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ import androidx.navigation.NavOptionsBuilder
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import me.proton.android.drive.ui.options.OptionsFilter
|
||||
import me.proton.android.drive.ui.viewmodel.FileOrFolderOptionsViewModel
|
||||
import me.proton.android.drive.ui.viewmodel.MoveToFolderViewModel
|
||||
import me.proton.android.drive.ui.viewmodel.MultipleFileOrFolderOptionsViewModel
|
||||
@@ -32,17 +34,16 @@ import me.proton.android.drive.ui.viewmodel.ParentFolderOptionsViewModel
|
||||
import me.proton.android.drive.ui.viewmodel.UploadToViewModel
|
||||
import me.proton.core.domain.entity.UserId
|
||||
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
|
||||
import me.proton.core.drive.drivelink.rename.presentation.RenameViewModel
|
||||
import me.proton.core.drive.drivelink.shared.presentation.viewmodel.SharedDriveLinkViewModel
|
||||
import me.proton.core.drive.folder.create.presentation.CreateFolderViewModel
|
||||
import me.proton.core.drive.link.domain.entity.FileId
|
||||
import me.proton.core.drive.link.domain.entity.FolderId
|
||||
import me.proton.core.drive.link.domain.entity.LinkId
|
||||
import me.proton.core.drive.drivelink.rename.presentation.RenameViewModel
|
||||
import me.proton.core.drive.drivelink.shared.presentation.viewmodel.SharedDriveLinkViewModel
|
||||
import me.proton.core.drive.link.selection.domain.entity.SelectionId
|
||||
import me.proton.core.drive.share.domain.entity.ShareId
|
||||
import me.proton.core.drive.sorting.domain.entity.By
|
||||
import me.proton.core.drive.sorting.domain.entity.Direction
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
sealed class Screen(val route: String) {
|
||||
open fun deepLink(baseUrl: String): String? = "$baseUrl/$route"
|
||||
@@ -88,26 +89,30 @@ sealed class Screen(val route: String) {
|
||||
}
|
||||
|
||||
object FileOrFolderOptions : Screen(
|
||||
"options/link/{userId}/shares/{shareId}/linkId={linkId}"
|
||||
"options/link/{userId}/shares/{shareId}/linkId={linkId}?optionsFilter={optionsFilter}"
|
||||
) {
|
||||
operator fun invoke(
|
||||
userId: UserId,
|
||||
linkId: LinkId,
|
||||
) = "options/link/${userId.id}/shares/${linkId.shareId.id}/linkId=${linkId.id}"
|
||||
optionsFilter: OptionsFilter = OptionsFilter.FILES,
|
||||
) = "options/link/${userId.id}/shares/${linkId.shareId.id}/linkId=${linkId.id}?optionsFilter=${optionsFilter.type}"
|
||||
|
||||
const val SHARE_ID = FileOrFolderOptionsViewModel.KEY_SHARE_ID
|
||||
const val LINK_ID = FileOrFolderOptionsViewModel.KEY_LINK_ID
|
||||
const val OPTIONS_FILTER = FileOrFolderOptionsViewModel.OPTIONS_FILTER
|
||||
}
|
||||
|
||||
object MultipleFileOrFolderOptions : Screen(
|
||||
"options/multiple/{userId}/selectionId={selectionId}"
|
||||
"options/multiple/{userId}/selectionId={selectionId}?optionsFilter={optionsFilter}"
|
||||
) {
|
||||
operator fun invoke(
|
||||
userId: UserId,
|
||||
selectionId: SelectionId,
|
||||
) = "options/multiple/${userId.id}/selectionId=${selectionId.id}"
|
||||
optionsFilter: OptionsFilter = OptionsFilter.FILES,
|
||||
) = "options/multiple/${userId.id}/selectionId=${selectionId.id}?optionsFilter=${optionsFilter.type}"
|
||||
|
||||
const val SELECTION_ID = MultipleFileOrFolderOptionsViewModel.KEY_SELECTION_ID
|
||||
const val OPTIONS_FILTER = MultipleFileOrFolderOptionsViewModel.OPTIONS_FILTER
|
||||
}
|
||||
|
||||
object ParentFolderOptions : Screen(
|
||||
@@ -245,6 +250,46 @@ sealed class Screen(val route: String) {
|
||||
const val USER_ID = Screen.USER_ID
|
||||
const val SHARE_ID = "shareId"
|
||||
}
|
||||
object Photos : Screen("home/{userId}/photos/{shareId}"), HomeTab {
|
||||
|
||||
override fun invoke(userId: UserId) = invoke(userId, null)
|
||||
|
||||
operator fun invoke(userId: UserId, shareId: ShareId?) = "home/${userId.id}/photos/${shareId?.id}"
|
||||
|
||||
const val USER_ID = Screen.USER_ID
|
||||
const val SHARE_ID = "shareId"
|
||||
}
|
||||
object BackupIssues : Screen("backup/issues/{userId}/shares/{shareId}/folder/{folderId}") {
|
||||
|
||||
fun invoke(folderId: FolderId) = "backup/issues/${folderId.shareId.userId.id}/shares/${folderId.shareId.id}/folder/${folderId.id}"
|
||||
|
||||
object Dialogs {
|
||||
object ConfirmSkipIssues : Screen("backup/issues/{userId}/shares/{shareId}/folder/{folderId}/confirm_skip?confirmPopUpRoute={confirmPopUpRoute}&confirmPopUpRouteInclusive={confirmPopUpRouteInclusive}"){
|
||||
operator fun invoke(
|
||||
folderId: FolderId,
|
||||
confirmPopUpRoute: String,
|
||||
confirmPopUpRouteInclusive: Boolean = true,
|
||||
) = "backup/issues/${folderId.shareId.userId.id}/shares/${folderId.shareId.id}/folder/${folderId.id}/confirm_skip?confirmPopUpRoute=${confirmPopUpRoute}&confirmPopUpRouteInclusive=${confirmPopUpRouteInclusive}"
|
||||
|
||||
const val CONFIRM_POP_UP_ROUTE = "confirmPopUpRoute"
|
||||
const val CONFIRM_POP_UP_ROUTE_INCLUSIVE = "confirmPopUpRouteInclusive"
|
||||
}
|
||||
}
|
||||
|
||||
const val USER_ID = Screen.USER_ID
|
||||
const val SHARE_ID = "shareId"
|
||||
const val FOLDER_ID = "folderId"
|
||||
}
|
||||
|
||||
object PhotosPermissionRationale : Screen(
|
||||
"home/{userId}/photosPermissionRationale"
|
||||
) {
|
||||
operator fun invoke(
|
||||
userId: UserId,
|
||||
) = "home/${userId.id}/photosPermissionRationale"
|
||||
|
||||
const val USER_ID = Screen.USER_ID
|
||||
}
|
||||
|
||||
object Trash : Screen("trash/{userId}") {
|
||||
operator fun invoke(userId: UserId) = "trash/${userId.id}"
|
||||
@@ -257,14 +302,20 @@ sealed class Screen(val route: String) {
|
||||
filesBrowsableBuildRoute("offline", userId, folderId, folderName)
|
||||
}
|
||||
|
||||
object PagerPreview : Screen("pager/{pagerType}/preview/{userId}/shares/{shareId}/files/{fileId}") {
|
||||
operator fun invoke(pagerType: PagerType, userId: UserId, fileId: FileId) =
|
||||
"pager/${pagerType.type}/preview/${userId.id}/shares/${fileId.shareId.id}/files/${fileId.id}"
|
||||
object PagerPreview : Screen("pager/{pagerType}/preview/{userId}/shares/{shareId}/files/{fileId}?optionsFilter={optionsFilter}") {
|
||||
operator fun invoke(
|
||||
pagerType: PagerType,
|
||||
userId: UserId,
|
||||
fileId: FileId,
|
||||
optionsFilter: OptionsFilter = OptionsFilter.FILES
|
||||
) =
|
||||
"pager/${pagerType.type}/preview/${userId.id}/shares/${fileId.shareId.id}/files/${fileId.id}?optionsFilter=${optionsFilter.type}"
|
||||
|
||||
const val USER_ID = Screen.USER_ID
|
||||
const val SHARE_ID = "shareId"
|
||||
const val FILE_ID = "fileId"
|
||||
const val PAGER_TYPE = "pagerType"
|
||||
const val OPTIONS_FILTER = FileOrFolderOptions.OPTIONS_FILTER
|
||||
}
|
||||
|
||||
object Settings : Screen("settings/{userId}") {
|
||||
@@ -287,6 +338,10 @@ sealed class Screen(val route: String) {
|
||||
object AutoLockDurations : Screen("settings/{userId}/autoLockDurations") {
|
||||
operator fun invoke(userId: UserId) = "settings/${userId.id}/autoLockDurations"
|
||||
}
|
||||
|
||||
object PhotosBackup : Screen("settings/{userId}/photosBackup") {
|
||||
operator fun invoke(userId: UserId) = "settings/${userId.id}/photosBackup"
|
||||
}
|
||||
}
|
||||
|
||||
object SendFile : Screen("send/{userId}/shares/{shareId}/files/{fileId}") {
|
||||
@@ -384,7 +439,7 @@ fun NavHostController.navigate(screen: Screen, builder: NavOptionsBuilder.() ->
|
||||
}
|
||||
|
||||
enum class PagerType(val type: String) {
|
||||
FOLDER("folder"), OFFLINE("offline"), SINGLE("single")
|
||||
FOLDER("folder"), OFFLINE("offline"), SINGLE("single"), PHOTO("photo")
|
||||
}
|
||||
|
||||
interface HomeTab {
|
||||
|
||||
@@ -15,58 +15,27 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.ui.navigation.animation
|
||||
|
||||
import androidx.compose.animation.AnimatedContentScope
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.navigation.NamedNavArgument
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavDeepLink
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import com.google.accompanist.navigation.animation.composable
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
fun NavGraphBuilder.slideComposable(
|
||||
route: String,
|
||||
arguments: List<NamedNavArgument> = emptyList(),
|
||||
deepLinks: List<NavDeepLink> = emptyList(),
|
||||
enterTransition:
|
||||
(AnimatedContentScope<NavBackStackEntry>.() -> EnterTransition?)? = defaultEnterSlideTransition(route),
|
||||
exitTransition:
|
||||
(AnimatedContentScope<NavBackStackEntry>.() -> ExitTransition?)? = defaultExitSlideTransition(route),
|
||||
popEnterTransition:
|
||||
(AnimatedContentScope<NavBackStackEntry>.() -> EnterTransition?)? = defaultPopEnterSlideTransition(route),
|
||||
popExitTransition:
|
||||
(AnimatedContentScope<NavBackStackEntry>.() -> ExitTransition?)? = defaultPopExitSlideTransition(route),
|
||||
content: @Composable AnimatedVisibilityScope.(NavBackStackEntry) -> Unit,
|
||||
) = composable(
|
||||
route = route,
|
||||
arguments = arguments,
|
||||
deepLinks = deepLinks,
|
||||
enterTransition = enterTransition,
|
||||
exitTransition = exitTransition,
|
||||
popEnterTransition = popEnterTransition,
|
||||
popExitTransition = popExitTransition,
|
||||
content = content,
|
||||
)
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
fun defaultEnterSlideTransition(
|
||||
route: String? = null,
|
||||
towards: AnimatedContentScope.SlideDirection = AnimatedContentScope.SlideDirection.Left,
|
||||
towards: AnimatedContentTransitionScope.SlideDirection = AnimatedContentTransitionScope.SlideDirection.Left,
|
||||
duration: Duration = DEFAULT_ANIMATION_DURATION,
|
||||
condition: AnimatedContentScope<NavBackStackEntry>.() -> Boolean = {
|
||||
condition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> Boolean = {
|
||||
initialState.destination.route == route && targetState.destination.route == route
|
||||
},
|
||||
): (AnimatedContentScope<NavBackStackEntry>.() -> EnterTransition?) = {
|
||||
): (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?) = {
|
||||
if (condition()) {
|
||||
slideIntoContainer(towards, tween(duration.inWholeMilliseconds.toInt()))
|
||||
} else {
|
||||
@@ -77,12 +46,12 @@ fun defaultEnterSlideTransition(
|
||||
@ExperimentalAnimationApi
|
||||
fun defaultExitSlideTransition(
|
||||
route: String? = null,
|
||||
towards: AnimatedContentScope.SlideDirection = AnimatedContentScope.SlideDirection.Left,
|
||||
towards: AnimatedContentTransitionScope.SlideDirection = AnimatedContentTransitionScope.SlideDirection.Left,
|
||||
duration: Duration = DEFAULT_ANIMATION_DURATION,
|
||||
condition: AnimatedContentScope<NavBackStackEntry>.() -> Boolean = {
|
||||
condition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> Boolean = {
|
||||
initialState.destination.route == route && targetState.destination.route == route
|
||||
},
|
||||
): (AnimatedContentScope<NavBackStackEntry>.() -> ExitTransition?) = {
|
||||
): (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?) = {
|
||||
if (condition()) {
|
||||
slideOutOfContainer(towards, tween(duration.inWholeMilliseconds.toInt()))
|
||||
} else {
|
||||
@@ -93,12 +62,12 @@ fun defaultExitSlideTransition(
|
||||
@ExperimentalAnimationApi
|
||||
fun defaultPopEnterSlideTransition(
|
||||
route: String? = null,
|
||||
towards: AnimatedContentScope.SlideDirection = AnimatedContentScope.SlideDirection.Right,
|
||||
towards: AnimatedContentTransitionScope.SlideDirection = AnimatedContentTransitionScope.SlideDirection.Right,
|
||||
duration: Duration = DEFAULT_ANIMATION_DURATION,
|
||||
condition: AnimatedContentScope<NavBackStackEntry>.() -> Boolean = {
|
||||
condition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> Boolean = {
|
||||
initialState.destination.route == route && targetState.destination.route == route
|
||||
},
|
||||
): (AnimatedContentScope<NavBackStackEntry>.() -> EnterTransition?) = {
|
||||
): (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?) = {
|
||||
if (condition()) {
|
||||
slideIntoContainer(towards, tween(duration.inWholeMilliseconds.toInt()))
|
||||
} else {
|
||||
@@ -109,12 +78,12 @@ fun defaultPopEnterSlideTransition(
|
||||
@ExperimentalAnimationApi
|
||||
fun defaultPopExitSlideTransition(
|
||||
route: String? = null,
|
||||
towards: AnimatedContentScope.SlideDirection = AnimatedContentScope.SlideDirection.Right,
|
||||
towards: AnimatedContentTransitionScope.SlideDirection = AnimatedContentTransitionScope.SlideDirection.Right,
|
||||
duration: Duration = DEFAULT_ANIMATION_DURATION,
|
||||
condition: AnimatedContentScope<NavBackStackEntry>.() -> Boolean = {
|
||||
condition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> Boolean = {
|
||||
initialState.destination.route == route && targetState.destination.route == route
|
||||
},
|
||||
): (AnimatedContentScope<NavBackStackEntry>.() -> ExitTransition?) = {
|
||||
): (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?) = {
|
||||
if (condition()) {
|
||||
slideOutOfContainer(towards, tween(duration.inWholeMilliseconds.toInt()))
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.ui.navigation.internal
|
||||
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.NavDestination
|
||||
import androidx.navigation.NavGraph
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.Navigator
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.createGraph
|
||||
import androidx.navigation.get
|
||||
|
||||
/**
|
||||
* Provides in place in the Compose hierarchy for self contained navigation to occur.
|
||||
*
|
||||
* Once this is called, any Composable within the given [NavGraphBuilder] can be navigated to from
|
||||
* the provided [navHostController].
|
||||
*
|
||||
* The builder passed into this method is [remember]ed. This means that for this NavHost, the
|
||||
* contents of the builder cannot be changed.
|
||||
*
|
||||
* @param navHostController the navController for this host
|
||||
* @param startDestination the route for the start destination
|
||||
* @param modifier The modifier to be applied to the layout.
|
||||
* @param route the route for the graph
|
||||
* @param builder the builder used to construct the graph
|
||||
*/
|
||||
|
||||
@Composable
|
||||
@ExperimentalAnimationApi
|
||||
fun DriveNavHost(
|
||||
navController: NavHostController,
|
||||
startDestination: String,
|
||||
modifier: Modifier = Modifier,
|
||||
route: String? = null,
|
||||
builder: NavGraphBuilder.() -> Unit,
|
||||
) {
|
||||
DriveNavHost(
|
||||
navController,
|
||||
remember(route, startDestination, builder) {
|
||||
navController.createGraph(startDestination, route, builder)
|
||||
},
|
||||
modifier
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Provides in place in the Compose hierarchy for self contained navigation to occur.
|
||||
*
|
||||
* Once this is called, any Composable within the given [NavGraphBuilder] can be navigated to from
|
||||
* the provided [navController].
|
||||
*
|
||||
* The graph passed into this method is [remember]ed. This means that for this NavHost, the graph
|
||||
* cannot be changed.
|
||||
*
|
||||
* @param navController the navController for this host
|
||||
* @param graph the graph for this host
|
||||
* @param modifier The modifier to be applied to the layout.
|
||||
*/
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun DriveNavHost(
|
||||
navController: NavHostController,
|
||||
graph: NavGraph,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
||||
NavHost(navController = navController, graph = graph, modifier = modifier)
|
||||
|
||||
val modalBottomSheetNavigator = navController.navigatorProvider.get<Navigator<out NavDestination>>(
|
||||
ModalBottomSheetNavigator.NAME
|
||||
) as? ModalBottomSheetNavigator ?: return
|
||||
|
||||
ModalBottomSheetHost(modalBottomSheetNavigator)
|
||||
}
|
||||
+26
-11
@@ -21,6 +21,7 @@ package me.proton.android.drive.ui.navigation.internal
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.ModalBottomSheetValue
|
||||
import androidx.compose.material.SwipeableDefaults
|
||||
import androidx.compose.material.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
@@ -45,7 +46,7 @@ import androidx.navigation.NavigatorState
|
||||
import androidx.navigation.compose.LocalOwnersProvider
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import me.proton.core.compose.component.bottomsheet.ModalBottomSheet
|
||||
import me.proton.core.drive.base.presentation.component.ModalBottomSheet
|
||||
import me.proton.core.compose.component.bottomsheet.ModalBottomSheetViewState
|
||||
import me.proton.core.compose.component.bottomsheet.RunAction
|
||||
|
||||
@@ -63,7 +64,7 @@ class ModalBottomSheetNavigator : Navigator<ModalBottomSheetNavigator.Destinatio
|
||||
* Dismiss the dialog destination associated with the given [backStackEntry].
|
||||
*/
|
||||
internal fun dismiss(backStackEntry: NavBackStackEntry) {
|
||||
popBackStack(backStackEntry, false)
|
||||
state.popWithTransition(backStackEntry, false)
|
||||
}
|
||||
|
||||
override fun onAttach(state: NavigatorState) {
|
||||
@@ -78,11 +79,15 @@ class ModalBottomSheetNavigator : Navigator<ModalBottomSheetNavigator.Destinatio
|
||||
) {
|
||||
entries.lastOrNull { entry -> entry.destination is Destination }?.let { toBeAdded ->
|
||||
if (backStack.value.none { entry -> entry.destination is Destination }) {
|
||||
state.push(toBeAdded)
|
||||
state.pushWithTransition(toBeAdded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun popBackStack(popUpTo: NavBackStackEntry, savedState: Boolean) {
|
||||
state.popWithTransition(popUpTo, savedState)
|
||||
}
|
||||
|
||||
override fun createDestination() =
|
||||
Destination()
|
||||
|
||||
@@ -104,11 +109,16 @@ class ModalBottomSheetNavigator : Navigator<ModalBottomSheetNavigator.Destinatio
|
||||
internal fun ModalBottomSheetHost(
|
||||
navigator: ModalBottomSheetNavigator,
|
||||
) {
|
||||
val saveableStateHolder = rememberSaveableStateHolder()
|
||||
val modalBackStack by remember(navigator.attached) { navigator.backStack }.collectAsState()
|
||||
val visibleBackStack = rememberVisibleList(modalBackStack)
|
||||
visibleBackStack.PopulateVisibleList(modalBackStack)
|
||||
visibleBackStack.forEach { backStackEntry ->
|
||||
ModalBottomSheetContent(backStackEntry) { navigator.dismiss(backStackEntry) }
|
||||
backStackEntry.LocalOwnersProvider(saveableStateHolder) {
|
||||
ModalBottomSheetContent(backStackEntry) {
|
||||
navigator.dismiss(backStackEntry)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,12 +131,17 @@ private fun ModalBottomSheetContent(
|
||||
) {
|
||||
val destination = backStackEntry.destination as ModalBottomSheetNavigator.Destination
|
||||
var dismissTrigger by remember(destination) { mutableStateOf(false) }
|
||||
val sheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) { value ->
|
||||
if (value == ModalBottomSheetValue.Hidden) {
|
||||
dismissTrigger = true
|
||||
}
|
||||
true
|
||||
}
|
||||
val sheetState = rememberModalBottomSheetState(
|
||||
initialValue = ModalBottomSheetValue.Hidden,
|
||||
animationSpec = SwipeableDefaults.AnimationSpec,
|
||||
skipHalfExpanded = true,
|
||||
confirmValueChange = { value ->
|
||||
if (value == ModalBottomSheetValue.Hidden) {
|
||||
dismissTrigger = true
|
||||
}
|
||||
true
|
||||
},
|
||||
)
|
||||
ModalBottomSheet(
|
||||
viewState = destination.viewState,
|
||||
sheetContent = { runAction ->
|
||||
@@ -147,7 +162,7 @@ private fun ModalBottomSheetContent(
|
||||
LaunchedEffect(destination) { sheetState.show() }
|
||||
LaunchedEffect(destination, dismissTrigger) {
|
||||
if (dismissTrigger) {
|
||||
while (sheetState.isAnimationRunning) {
|
||||
while (sheetState.isVisible) {
|
||||
delay(10)
|
||||
}
|
||||
onDismiss()
|
||||
|
||||
-2
@@ -34,7 +34,6 @@ import androidx.navigation.NavHostController
|
||||
import androidx.navigation.Navigator
|
||||
import androidx.navigation.compose.ComposeNavigator
|
||||
import androidx.navigation.compose.DialogNavigator
|
||||
import com.google.accompanist.navigation.animation.AnimatedComposeNavigator
|
||||
import me.proton.core.crypto.common.keystore.KeyStoreCrypto
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@@ -59,7 +58,6 @@ fun createNavController(context: Context) =
|
||||
navigatorProvider.addNavigator(ComposeNavigator())
|
||||
navigatorProvider.addNavigator(DialogNavigator())
|
||||
navigatorProvider.addNavigator(ModalBottomSheetNavigator())
|
||||
navigatorProvider.addNavigator(AnimatedComposeNavigator())
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -307,6 +307,22 @@ fun Set<Option>.filter(driveLink: DriveLink) =
|
||||
option.applicableStates.containsAll(driveLink.toOptionState()) && driveLink.isApplicableTo(option.applicableTo)
|
||||
}
|
||||
|
||||
private val photosOptions = listOf(
|
||||
Option.OfflineToggle,
|
||||
Option.ShareViaLink,
|
||||
Option.CopySharedLink,
|
||||
Option.SendFile,
|
||||
Option.Download,
|
||||
Option.Info,
|
||||
Option.Trash,
|
||||
)
|
||||
|
||||
fun Iterable<Option>.filter(optionsFilter: OptionsFilter) =
|
||||
when (optionsFilter) {
|
||||
OptionsFilter.FILES -> this
|
||||
OptionsFilter.PHOTOS -> filter { option -> option in photosOptions }
|
||||
}
|
||||
|
||||
fun Set<Option>.filterAll(driveLinks: List<DriveLink>) =
|
||||
filter { option ->
|
||||
driveLinks.size <= option.applicableQuantity.quantity &&
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package me.proton.android.drive.ui.options
|
||||
|
||||
enum class OptionsFilter(val type: String) {
|
||||
FILES("files"), PHOTOS("photos")
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.ui.screen
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import me.proton.android.drive.photos.presentation.component.BackupIssues
|
||||
import me.proton.android.drive.ui.viewmodel.BackupIssuesViewModel
|
||||
import me.proton.core.compose.flow.rememberFlowWithLifecycle
|
||||
|
||||
@Composable
|
||||
fun BackupIssuesScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
navigateBack: () -> Unit,
|
||||
navigateToSkipIssues: () -> Unit,
|
||||
) {
|
||||
val viewModel = hiltViewModel<BackupIssuesViewModel>()
|
||||
val viewState by rememberFlowWithLifecycle(flow = viewModel.viewState)
|
||||
.collectAsState(initial = viewModel.initialViewState)
|
||||
val viewEvent = remember {
|
||||
viewModel.viewEvent(
|
||||
navigateBack = navigateBack,
|
||||
navigateToSkipIssues = navigateToSkipIssues,
|
||||
)
|
||||
}
|
||||
|
||||
BackupIssues(
|
||||
medias = viewState.medias,
|
||||
onBack = viewEvent.onBack,
|
||||
onSkip = viewEvent.onSkip,
|
||||
onRetryAll = viewEvent.onRetryAll,
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.statusBarsPadding(),
|
||||
)
|
||||
}
|
||||
@@ -71,7 +71,7 @@ fun FileInfo(
|
||||
viewState?.let {
|
||||
FileInfoContent(
|
||||
driveLink = viewState.link,
|
||||
pathToFileNode = viewState.path,
|
||||
items = viewState.items,
|
||||
modifier = Modifier
|
||||
.padding(top = DefaultSpacing)
|
||||
.padding(horizontal = DefaultSpacing)
|
||||
|
||||
@@ -28,19 +28,17 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import me.proton.android.drive.ui.common.ProtonSwipeRefresh
|
||||
import me.proton.android.drive.ui.effect.HandleHomeEffect
|
||||
import me.proton.android.drive.ui.viewmodel.FilesViewModel
|
||||
import me.proton.android.drive.ui.viewstate.HomeScaffoldState
|
||||
import me.proton.core.compose.flow.rememberFlowWithLifecycle
|
||||
import me.proton.core.drive.base.presentation.component.ActionButton
|
||||
import me.proton.core.drive.base.presentation.component.ProtonPullToRefresh
|
||||
import me.proton.core.drive.base.presentation.component.TopBarActions
|
||||
import me.proton.core.drive.files.presentation.component.DriveLinksFlow
|
||||
import me.proton.core.drive.files.presentation.component.Files
|
||||
import me.proton.core.drive.files.presentation.component.TopAppBar
|
||||
import me.proton.core.drive.files.presentation.state.FilesViewState
|
||||
import me.proton.core.drive.link.domain.entity.FileId
|
||||
import me.proton.core.drive.link.domain.entity.FolderId
|
||||
import me.proton.core.drive.link.domain.entity.LinkId
|
||||
@@ -98,10 +96,10 @@ fun FilesScreen(
|
||||
}
|
||||
viewModel.HandleHomeEffect(homeScaffoldState)
|
||||
|
||||
ProtonSwipeRefresh(
|
||||
listContentState = viewState.listContentState,
|
||||
ProtonPullToRefresh(
|
||||
isPullToRefreshEnabled = viewState.isRefreshEnabled,
|
||||
isRefreshing = viewState.listContentState.isRefreshing,
|
||||
onRefresh = viewModel::refresh,
|
||||
swipeEnabled = viewState.isRefreshEnabled,
|
||||
) {
|
||||
Files(
|
||||
driveLinks = DriveLinksFlow.PagingList(viewModel.driveLinks, viewModel.listEffect),
|
||||
@@ -115,20 +113,6 @@ fun FilesScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TopBarActions(
|
||||
actionFlow: Flow<Set<FilesViewState.Action>>,
|
||||
) {
|
||||
val actions by rememberFlowWithLifecycle(flow = actionFlow).collectAsState(initial = emptySet())
|
||||
actions.forEach { action ->
|
||||
ActionButton(
|
||||
icon = action.iconResId,
|
||||
contentDescription = action.contentDescriptionResId,
|
||||
onClick = action.onAction,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
object MyFilesScreenTestTag {
|
||||
const val screen = "my files screen"
|
||||
}
|
||||
|
||||
@@ -35,9 +35,9 @@ import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -48,20 +48,22 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import me.proton.android.drive.ui.navigation.HomeNavGraph
|
||||
import me.proton.android.drive.ui.navigation.PagerType
|
||||
import me.proton.android.drive.ui.navigation.Screen
|
||||
import me.proton.android.drive.ui.options.OptionsFilter
|
||||
import me.proton.android.drive.ui.provider.setLocalSnackbarPadding
|
||||
import me.proton.android.drive.ui.viewevent.HomeViewEvent
|
||||
import me.proton.android.drive.ui.viewmodel.HomeViewModel
|
||||
import me.proton.android.drive.ui.viewstate.HomeViewState
|
||||
import me.proton.android.drive.ui.viewstate.rememberHomeScaffoldState
|
||||
import me.proton.core.compose.component.DeferredCircularProgressIndicator
|
||||
import me.proton.core.compose.component.ProtonSnackbarHost
|
||||
import me.proton.core.compose.component.ProtonSnackbarHostState
|
||||
import me.proton.core.compose.component.ProtonSnackbarType
|
||||
import me.proton.core.compose.component.bottomsheet.ModalBottomSheet
|
||||
import me.proton.core.compose.component.bottomsheet.ModalBottomSheetViewState
|
||||
import me.proton.core.compose.flow.rememberFlowWithLifecycle
|
||||
import me.proton.core.compose.theme.ProtonTheme
|
||||
import me.proton.core.domain.entity.UserId
|
||||
import me.proton.core.drive.base.presentation.component.BottomNavigation
|
||||
import me.proton.core.drive.base.presentation.component.ModalBottomSheet
|
||||
import me.proton.core.drive.link.domain.entity.FileId
|
||||
import me.proton.core.drive.link.domain.entity.FolderId
|
||||
import me.proton.core.drive.link.domain.entity.LinkId
|
||||
@@ -83,13 +85,15 @@ fun HomeScreen(
|
||||
navigateToSigningOut: () -> Unit,
|
||||
navigateToTrash: () -> Unit,
|
||||
navigateToOffline: () -> Unit,
|
||||
navigateToPreview: (fileId: FileId, pagerType: PagerType) -> Unit,
|
||||
navigateToPreview: (fileId: FileId, pagerType: PagerType, optionsFilter: OptionsFilter) -> Unit,
|
||||
navigateToSorting: (sorting: Sorting) -> Unit,
|
||||
navigateToSettings: () -> Unit,
|
||||
navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit,
|
||||
navigateToMultipleFileOrFolderOptions: (selectionId: SelectionId) -> Unit,
|
||||
navigateToFileOrFolderOptions: (linkId: LinkId, optionsFilter: OptionsFilter) -> Unit,
|
||||
navigateToMultipleFileOrFolderOptions: (selectionId: SelectionId, optionsFilter: OptionsFilter) -> Unit,
|
||||
navigateToParentFolderOptions: (folderId: FolderId) -> Unit,
|
||||
navigateToSubscription: () -> Unit,
|
||||
navigateToPhotosIssues: (FolderId) -> Unit,
|
||||
navigateToPhotosPermissionRationale: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
setLocalSnackbarPadding(BottomNavigationHeight)
|
||||
@@ -105,37 +109,42 @@ fun HomeScreen(
|
||||
}
|
||||
}
|
||||
val viewState by rememberFlowWithLifecycle(flow = homeViewModel.viewState)
|
||||
.collectAsState(initial = homeViewModel.initialViewState)
|
||||
Home(
|
||||
homeNavController = homeNavController,
|
||||
deepLinkBaseUrl = deepLinkBaseUrl,
|
||||
startDestination = startDestination,
|
||||
onDrawerStateChanged = onDrawerStateChanged,
|
||||
navigateToPreview = navigateToPreview,
|
||||
navigateToSorting = navigateToSorting,
|
||||
navigateToFileOrFolderOptions = navigateToFileOrFolderOptions,
|
||||
navigateToMultipleFileOrFolderOptions = navigateToMultipleFileOrFolderOptions,
|
||||
navigateToParentFolderOptions = navigateToParentFolderOptions,
|
||||
arguments = arguments,
|
||||
viewState = viewState,
|
||||
viewEvent = homeViewModel.viewEvent(
|
||||
navigateToSigningOut = navigateToSigningOut,
|
||||
navigateToTrash = navigateToTrash,
|
||||
navigateToTab = { route ->
|
||||
homeNavController.navigate(route) {
|
||||
popUpTo(Screen.Files.route) { inclusive = route == Screen.Files(userId) }
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
navigateToOffline = navigateToOffline,
|
||||
navigateToSettings = navigateToSettings,
|
||||
navigateToBugReport = navigateToBugReport,
|
||||
.collectAsState(initial = null)
|
||||
viewState?.let { currentViewState ->
|
||||
Home(
|
||||
homeNavController = homeNavController,
|
||||
deepLinkBaseUrl = deepLinkBaseUrl,
|
||||
startDestination = startDestination,
|
||||
onDrawerStateChanged = onDrawerStateChanged,
|
||||
navigateToPreview = navigateToPreview,
|
||||
navigateToSorting = navigateToSorting,
|
||||
navigateToFileOrFolderOptions = navigateToFileOrFolderOptions,
|
||||
navigateToMultipleFileOrFolderOptions = navigateToMultipleFileOrFolderOptions,
|
||||
navigateToParentFolderOptions = navigateToParentFolderOptions,
|
||||
navigateToPhotosPermissionRationale = navigateToPhotosPermissionRationale,
|
||||
navigateToSubscription = navigateToSubscription,
|
||||
),
|
||||
modifier = modifier
|
||||
.navigationBarsPadding()
|
||||
.testTag(HomeScreenTestTag.screen),
|
||||
)
|
||||
navigateToPhotosIssues = navigateToPhotosIssues,
|
||||
arguments = arguments,
|
||||
viewState = currentViewState,
|
||||
viewEvent = homeViewModel.viewEvent(
|
||||
navigateToSigningOut = navigateToSigningOut,
|
||||
navigateToTrash = navigateToTrash,
|
||||
navigateToTab = { route ->
|
||||
homeNavController.navigate(route) {
|
||||
popUpTo(Screen.Files.route) { inclusive = route == Screen.Files(userId) }
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
navigateToOffline = navigateToOffline,
|
||||
navigateToSettings = navigateToSettings,
|
||||
navigateToBugReport = navigateToBugReport,
|
||||
navigateToSubscription = navigateToSubscription,
|
||||
),
|
||||
modifier = modifier
|
||||
.navigationBarsPadding()
|
||||
.testTag(HomeScreenTestTag.screen),
|
||||
)
|
||||
} ?: DeferredCircularProgressIndicator(modifier)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@@ -147,15 +156,18 @@ internal fun Home(
|
||||
deepLinkBaseUrl: String,
|
||||
startDestination: String,
|
||||
onDrawerStateChanged: (Boolean) -> Unit,
|
||||
navigateToPreview: (fileId: FileId, pagerType: PagerType) -> Unit,
|
||||
navigateToPreview: (fileId: FileId, pagerType: PagerType, optionsFilter: OptionsFilter) -> Unit,
|
||||
navigateToSorting: (sorting: Sorting) -> Unit,
|
||||
navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit,
|
||||
navigateToMultipleFileOrFolderOptions: (selectionId: SelectionId) -> Unit,
|
||||
navigateToFileOrFolderOptions: (linkId: LinkId, optionsFilter: OptionsFilter) -> Unit,
|
||||
navigateToMultipleFileOrFolderOptions: (selectionId: SelectionId, optionsFilter: OptionsFilter) -> Unit,
|
||||
navigateToParentFolderOptions: (folderId: FolderId) -> Unit,
|
||||
arguments: Bundle,
|
||||
viewState: HomeViewState,
|
||||
viewEvent: HomeViewEvent,
|
||||
modifier: Modifier = Modifier,
|
||||
navigateToPhotosPermissionRationale: () -> Unit,
|
||||
navigateToSubscription: () -> Unit,
|
||||
navigateToPhotosIssues: (FolderId) -> Unit,
|
||||
) {
|
||||
val homeScaffoldState = rememberHomeScaffoldState()
|
||||
val isDrawerOpen = with(homeScaffoldState.scaffoldState.drawerState) {
|
||||
@@ -244,6 +256,9 @@ internal fun Home(
|
||||
navigateToFileOrFolderOptions,
|
||||
navigateToMultipleFileOrFolderOptions,
|
||||
navigateToParentFolderOptions,
|
||||
navigateToPhotosPermissionRationale,
|
||||
navigateToSubscription,
|
||||
navigateToPhotosIssues,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.ui.screen
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.material.Switch
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import me.proton.android.drive.photos.presentation.component.BackupPermissions
|
||||
import me.proton.android.drive.ui.action.PhotoExtractDataAction
|
||||
import me.proton.android.drive.ui.viewevent.PhotosBackupViewEvent
|
||||
import me.proton.android.drive.ui.viewmodel.PhotosBackupViewModel
|
||||
import me.proton.android.drive.ui.viewstate.PhotosBackupViewState
|
||||
import me.proton.core.compose.component.ProtonRawListItem
|
||||
import me.proton.core.compose.flow.rememberFlowWithLifecycle
|
||||
import me.proton.core.compose.theme.ProtonDimens.DefaultSpacing
|
||||
import me.proton.core.compose.theme.ProtonDimens.ListItemHeight
|
||||
import me.proton.core.compose.theme.ProtonTheme
|
||||
import me.proton.core.compose.theme.defaultNorm
|
||||
import me.proton.core.drive.base.presentation.component.TopAppBar
|
||||
import me.proton.core.presentation.R as CorePresentation
|
||||
|
||||
@Composable
|
||||
fun PhotosBackupScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
navigateToPhotosPermissionRationale: () -> Unit,
|
||||
navigateBack: () -> Unit,
|
||||
) {
|
||||
val viewModel = hiltViewModel<PhotosBackupViewModel>()
|
||||
val viewState by rememberFlowWithLifecycle(flow = viewModel.viewState)
|
||||
.collectAsState(initial = viewModel.initialViewState)
|
||||
val viewEvent = remember {
|
||||
viewModel.viewEvent(navigateBack = navigateBack)
|
||||
}
|
||||
PhotosBackup(
|
||||
viewState = viewState,
|
||||
viewEvent = viewEvent,
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.systemBarsPadding(),
|
||||
navigateBack = navigateBack,
|
||||
)
|
||||
BackupPermissions(
|
||||
viewState = viewModel.backupPermissionsViewModel.initialViewState,
|
||||
viewEvent = viewModel.backupPermissionsViewModel.viewEvent(
|
||||
navigateToPhotosPermissionRationale
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PhotosBackup(
|
||||
viewState: PhotosBackupViewState,
|
||||
viewEvent: PhotosBackupViewEvent,
|
||||
modifier: Modifier = Modifier,
|
||||
navigateBack: () -> Unit,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
TopAppBar(
|
||||
navigationIcon = painterResource(id = CorePresentation.drawable.ic_arrow_back),
|
||||
onNavigationIcon = navigateBack,
|
||||
title = viewState.title,
|
||||
)
|
||||
BackupPhotosOptions(
|
||||
enableBackupTitle = viewState.enableBackupTitle,
|
||||
isBackupEnabled = viewState.isBackupEnabled,
|
||||
) {
|
||||
viewEvent.onToggle()
|
||||
}
|
||||
PhotoExtractDataAction()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BackupPhotosOptions(
|
||||
enableBackupTitle: String,
|
||||
isBackupEnabled: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
BackupPhotosToggle(
|
||||
title = enableBackupTitle,
|
||||
isEnabled = isBackupEnabled,
|
||||
onToggle = onToggle,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BackupPhotosToggle(
|
||||
title: String,
|
||||
isEnabled: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
ProtonRawListItem(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.sizeIn(minHeight = ListItemHeight)
|
||||
.toggleable(
|
||||
value = isEnabled,
|
||||
enabled = true,
|
||||
role = Role.Switch,
|
||||
onValueChange = { onToggle() },
|
||||
)
|
||||
.padding(horizontal = DefaultSpacing)
|
||||
.testTag(PhotosBackupSettingsScreenTestTag.previewBackupToggle),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = ProtonTheme.typography.defaultNorm,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Switch(
|
||||
checked = isEnabled,
|
||||
onCheckedChange = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PhotosBackupPreview() {
|
||||
ProtonTheme {
|
||||
PhotosBackup(
|
||||
viewState = PhotosBackupViewState(
|
||||
title = "Photos Backup",
|
||||
enableBackupTitle = "Photos Backup",
|
||||
isBackupEnabled = true,
|
||||
),
|
||||
viewEvent = object : PhotosBackupViewEvent {
|
||||
override val onToggle = {}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
navigateBack = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PreviewBackupPhotosToggle() {
|
||||
ProtonTheme {
|
||||
BackupPhotosToggle(
|
||||
title = "Photos backup",
|
||||
isEnabled = true,
|
||||
onToggle = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
object PhotosBackupSettingsScreenTestTag {
|
||||
const val previewBackupToggle = "backup preview toggle"
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.ui.screen
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.paging.PagingData
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import me.proton.android.drive.photos.presentation.component.BackupPermissions
|
||||
import me.proton.android.drive.photos.presentation.component.Photos
|
||||
import me.proton.android.drive.photos.presentation.component.PhotosStatesIndicator
|
||||
import me.proton.android.drive.photos.presentation.state.PhotosItem
|
||||
import me.proton.android.drive.photos.presentation.viewevent.PhotosViewEvent
|
||||
import me.proton.android.drive.photos.presentation.viewstate.PhotosViewState
|
||||
import me.proton.android.drive.ui.effect.HandleHomeEffect
|
||||
import me.proton.android.drive.ui.viewmodel.PhotosViewModel
|
||||
import me.proton.android.drive.ui.viewstate.HomeScaffoldState
|
||||
import me.proton.core.compose.flow.rememberFlowWithLifecycle
|
||||
import me.proton.core.drive.base.presentation.component.TopAppBar
|
||||
import me.proton.core.drive.base.presentation.component.TopBarActions
|
||||
import me.proton.core.drive.drivelink.domain.entity.DriveLink
|
||||
import me.proton.core.drive.files.presentation.state.ListEffect
|
||||
import me.proton.core.drive.link.domain.entity.FileId
|
||||
import me.proton.core.drive.link.domain.entity.FolderId
|
||||
import me.proton.core.drive.link.domain.entity.LinkId
|
||||
import me.proton.core.drive.link.selection.domain.entity.SelectionId
|
||||
|
||||
@Composable
|
||||
fun PhotosScreen(
|
||||
homeScaffoldState: HomeScaffoldState,
|
||||
modifier: Modifier = Modifier,
|
||||
navigateToPhotosPermissionRationale: () -> Unit,
|
||||
navigateToPhotosPreview: (fileId: FileId) -> Unit,
|
||||
navigateToPhotosOptions: (fileId: FileId) -> Unit,
|
||||
navigateToMultiplePhotosOptions: (selectionId: SelectionId) -> Unit,
|
||||
navigateToSubscription: () -> Unit,
|
||||
navigateToPhotosIssues: (FolderId) -> Unit,
|
||||
) {
|
||||
val viewModel = hiltViewModel<PhotosViewModel>()
|
||||
val viewState by rememberFlowWithLifecycle(flow = viewModel.viewState)
|
||||
.collectAsState(initial = viewModel.initialViewState)
|
||||
val viewEvent = remember {
|
||||
viewModel.viewEvent(
|
||||
navigateToPreview = navigateToPhotosPreview,
|
||||
navigateToPhotosOptions = navigateToPhotosOptions,
|
||||
navigateToMultiplePhotosOptions = navigateToMultiplePhotosOptions,
|
||||
navigateToSubscription = navigateToSubscription,
|
||||
navigateToPhotosIssues = navigateToPhotosIssues,
|
||||
)
|
||||
}
|
||||
val photos = rememberFlowWithLifecycle(flow = viewModel.driveLinks)
|
||||
val listEffect = rememberFlowWithLifecycle(flow = viewModel.listEffect)
|
||||
|
||||
viewModel.HandleHomeEffect(homeScaffoldState)
|
||||
|
||||
PhotosScreen(
|
||||
viewState = viewState,
|
||||
viewEvent = viewEvent,
|
||||
homeScaffoldState = homeScaffoldState,
|
||||
photos = photos,
|
||||
listEffect = listEffect,
|
||||
driveLinksFlow = viewModel.driveLinksMap,
|
||||
modifier = modifier,
|
||||
)
|
||||
BackupPermissions(
|
||||
viewState = viewModel.backupPermissionsViewModel.initialViewState,
|
||||
viewEvent = viewModel.backupPermissionsViewModel.viewEvent(
|
||||
navigateToPhotosPermissionRationale
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PhotosScreen(
|
||||
viewState: PhotosViewState,
|
||||
viewEvent: PhotosViewEvent,
|
||||
homeScaffoldState: HomeScaffoldState,
|
||||
photos: Flow<PagingData<PhotosItem>>,
|
||||
listEffect: Flow<ListEffect>,
|
||||
driveLinksFlow: Flow<Map<LinkId, DriveLink>>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val selectedPhotos by rememberFlowWithLifecycle(flow = viewState.selected)
|
||||
.collectAsState(initial = emptySet())
|
||||
val inMultiselect = remember(selectedPhotos) { selectedPhotos.isNotEmpty() }
|
||||
|
||||
BackHandler(enabled = inMultiselect) { viewEvent.onBack() }
|
||||
|
||||
LaunchedEffect(viewState) {
|
||||
homeScaffoldState.topAppBar.value = {
|
||||
TopAppBar(
|
||||
viewState = viewState,
|
||||
viewEvent = viewEvent,
|
||||
) {
|
||||
TopBarActions(actionFlow = viewState.topBarActions)
|
||||
AnimatedVisibility(
|
||||
visible = viewState.showPhotosStateIndicator,
|
||||
) {
|
||||
IconButton(
|
||||
modifier = modifier.clip(shape = CircleShape),
|
||||
onClick = { viewEvent.onStatusClicked() }
|
||||
) {
|
||||
viewState.backupStatusViewState?.let { backupStatusViewState ->
|
||||
PhotosStatesIndicator(backupStatusViewState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Photos(
|
||||
viewState = viewState,
|
||||
viewEvent = viewEvent,
|
||||
photos = photos,
|
||||
listEffect = listEffect,
|
||||
driveLinksFlow = driveLinksFlow,
|
||||
selectedPhotos = selectedPhotos,
|
||||
modifier = modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TopAppBar(
|
||||
viewState: PhotosViewState,
|
||||
viewEvent: PhotosViewEvent,
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
) {
|
||||
TopAppBar(
|
||||
navigationIcon = if (viewState.navigationIconResId != 0) {
|
||||
painterResource(id = viewState.navigationIconResId)
|
||||
} else null,
|
||||
onNavigationIcon = viewEvent.onTopAppBarNavigation,
|
||||
title = viewState.title,
|
||||
actions = actions
|
||||
)
|
||||
}
|
||||
@@ -38,11 +38,11 @@ import me.proton.android.drive.ui.MainActivity
|
||||
import me.proton.android.drive.ui.effect.PreviewEffect
|
||||
import me.proton.android.drive.ui.viewmodel.PreviewViewModel
|
||||
import me.proton.core.compose.activity.KeepScreenOn
|
||||
import me.proton.core.compose.component.bottomsheet.ModalBottomSheet
|
||||
import me.proton.core.compose.component.bottomsheet.ModalBottomSheetViewState
|
||||
import me.proton.core.compose.component.bottomsheet.rememberModalBottomSheetContentState
|
||||
import me.proton.core.compose.flow.rememberFlowWithLifecycle
|
||||
import me.proton.core.drive.base.presentation.component.Deferred
|
||||
import me.proton.core.drive.base.presentation.component.ModalBottomSheet
|
||||
import me.proton.core.drive.files.preview.presentation.component.Preview
|
||||
import me.proton.core.drive.files.preview.presentation.component.PreviewEmpty
|
||||
import me.proton.core.drive.files.preview.presentation.component.state.PreviewContentState
|
||||
|
||||
@@ -44,6 +44,7 @@ fun SettingsScreen(
|
||||
navigateBack: () -> Unit,
|
||||
navigateToAppAccess: () -> Unit,
|
||||
navigateToAutoLockDurations: () -> Unit,
|
||||
navigateToPhotosBackup: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val viewModel = hiltViewModel<SettingsViewModel>()
|
||||
@@ -70,6 +71,7 @@ fun SettingsScreen(
|
||||
navigateBack = navigateBack,
|
||||
navigateToAppAccess = navigateToAppAccess,
|
||||
navigateToAutoLockDurations = navigateToAutoLockDurations,
|
||||
navigateToPhotosBackup = navigateToPhotosBackup,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -27,11 +27,11 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import me.proton.android.drive.ui.common.ProtonSwipeRefresh
|
||||
import me.proton.android.drive.ui.effect.HandleHomeEffect
|
||||
import me.proton.android.drive.ui.viewmodel.SharedViewModel
|
||||
import me.proton.android.drive.ui.viewstate.HomeScaffoldState
|
||||
import me.proton.core.compose.flow.rememberFlowWithLifecycle
|
||||
import me.proton.core.drive.base.presentation.component.ProtonPullToRefresh
|
||||
import me.proton.core.drive.files.presentation.component.DriveLinksFlow
|
||||
import me.proton.core.drive.files.presentation.component.Files
|
||||
import me.proton.core.drive.files.presentation.component.TopAppBar
|
||||
@@ -74,8 +74,9 @@ fun SharedScreen(
|
||||
}
|
||||
}
|
||||
|
||||
ProtonSwipeRefresh(
|
||||
listContentState = viewState.filesViewState.listContentState,
|
||||
ProtonPullToRefresh(
|
||||
isPullToRefreshEnabled = true,
|
||||
isRefreshing = viewState.filesViewState.listContentState.isRefreshing,
|
||||
onRefresh = viewModel::refresh,
|
||||
) {
|
||||
Files(
|
||||
|
||||
@@ -38,7 +38,6 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import me.proton.android.drive.ui.common.ProtonSwipeRefresh
|
||||
import me.proton.android.drive.ui.effect.TrashEffect
|
||||
import me.proton.android.drive.ui.viewmodel.TrashViewModel
|
||||
import me.proton.core.compose.component.ProtonSnackbarHost
|
||||
@@ -46,7 +45,6 @@ import me.proton.core.compose.component.ProtonSnackbarHostState
|
||||
import me.proton.core.compose.component.ProtonSnackbarType
|
||||
import me.proton.core.compose.component.bottomsheet.BottomSheetContent
|
||||
import me.proton.core.compose.component.bottomsheet.BottomSheetEntry
|
||||
import me.proton.core.compose.component.bottomsheet.ModalBottomSheet
|
||||
import me.proton.core.compose.component.bottomsheet.ModalBottomSheetViewState
|
||||
import me.proton.core.compose.component.bottomsheet.rememberModalBottomSheetContentState
|
||||
import me.proton.core.compose.flow.rememberFlowWithLifecycle
|
||||
@@ -54,6 +52,8 @@ import me.proton.core.compose.theme.ProtonDimens.ExtraSmallSpacing
|
||||
import me.proton.core.compose.theme.ProtonTheme
|
||||
import me.proton.core.compose.theme.defaultSmallStrong
|
||||
import me.proton.core.drive.base.presentation.component.ActionButton
|
||||
import me.proton.core.drive.base.presentation.component.ModalBottomSheet
|
||||
import me.proton.core.drive.base.presentation.component.ProtonPullToRefresh
|
||||
import me.proton.core.drive.base.presentation.component.TopAppBarHeight
|
||||
import me.proton.core.drive.files.presentation.component.DriveLinksFlow
|
||||
import me.proton.core.drive.files.presentation.component.Files
|
||||
@@ -115,8 +115,9 @@ fun TrashScreen(
|
||||
Box(
|
||||
modifier = modifier.systemBarsPadding()
|
||||
) {
|
||||
ProtonSwipeRefresh(
|
||||
listContentState = viewState.listContentState,
|
||||
ProtonPullToRefresh(
|
||||
isPullToRefreshEnabled = true,
|
||||
isRefreshing = viewState.listContentState.isRefreshing,
|
||||
onRefresh = viewModel::refresh,
|
||||
topPadding = TopAppBarHeight,
|
||||
) {
|
||||
|
||||
@@ -20,6 +20,7 @@ package me.proton.android.drive.ui.screen
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -34,6 +35,8 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.Text
|
||||
@@ -52,9 +55,6 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
||||
import com.google.accompanist.pager.HorizontalPager
|
||||
import com.google.accompanist.pager.rememberPagerState
|
||||
import kotlinx.coroutines.launch
|
||||
import me.proton.android.drive.ui.viewevent.WelcomeViewEvent
|
||||
import me.proton.android.drive.ui.viewmodel.WelcomeViewModel
|
||||
@@ -109,7 +109,7 @@ fun Welcome(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPagerApi::class)
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun Welcome(
|
||||
items: List<WelcomeViewState>,
|
||||
@@ -117,11 +117,10 @@ fun Welcome(
|
||||
navigateToLauncher: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val pagerState = rememberPagerState(initialPage = 0)
|
||||
val pagerState = rememberPagerState(initialPage = 0) { items.size }
|
||||
val scope = rememberCoroutineScope()
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
count = items.size,
|
||||
modifier = modifier,
|
||||
) { page ->
|
||||
Welcome(
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.ui.viewevent
|
||||
|
||||
interface BackupIssuesViewEvent {
|
||||
val onBack: () -> Unit get() = {}
|
||||
val onRetryAll: () -> Unit get() = {}
|
||||
val onSkip: () -> Unit get() = {}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.ui.viewevent
|
||||
|
||||
interface ConfirmSkipIssuesViewEvent {
|
||||
val onConfirm: () -> Unit get() = {}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.ui.viewevent
|
||||
|
||||
interface PhotosBackupViewEvent {
|
||||
val onToggle: () -> Unit
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.ui.viewevent
|
||||
|
||||
import android.content.Context
|
||||
|
||||
interface PhotosExportDataViewEvent {
|
||||
val onExportData: (Context) -> Unit
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.ui.viewmodel
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import me.proton.android.drive.ui.navigation.Screen
|
||||
import me.proton.android.drive.ui.viewevent.BackupIssuesViewEvent
|
||||
import me.proton.android.drive.ui.viewstate.BackupIssuesViewState
|
||||
import me.proton.core.drive.backup.domain.usecase.GetAllFailedFiles
|
||||
import me.proton.core.drive.backup.domain.usecase.RetryBackup
|
||||
import me.proton.core.drive.base.data.extension.getDefaultMessage
|
||||
import me.proton.core.drive.base.data.extension.log
|
||||
import me.proton.core.drive.base.domain.log.LogTag.BACKUP
|
||||
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
|
||||
import me.proton.core.drive.base.domain.usecase.BroadcastMessages
|
||||
import me.proton.core.drive.base.presentation.extension.require
|
||||
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
|
||||
import me.proton.core.drive.link.domain.entity.FolderId
|
||||
import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
|
||||
import me.proton.core.drive.share.domain.entity.ShareId
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@Suppress("StaticFieldLeak", "LongParameterList")
|
||||
class BackupIssuesViewModel @Inject constructor(
|
||||
@ApplicationContext private val appContext: Context,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
getAllFailedFiles: GetAllFailedFiles,
|
||||
private val retryBackup: RetryBackup,
|
||||
private val configurationProvider: ConfigurationProvider,
|
||||
private val broadcastMessages: BroadcastMessages,
|
||||
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
|
||||
|
||||
val shareId = ShareId(userId, savedStateHandle.require(Screen.BackupIssues.SHARE_ID))
|
||||
val folderId: FolderId =
|
||||
FolderId(shareId, savedStateHandle.require(Screen.BackupIssues.FOLDER_ID))
|
||||
|
||||
|
||||
val initialViewState = BackupIssuesViewState(medias = emptyList())
|
||||
|
||||
val viewState: Flow<BackupIssuesViewState> =
|
||||
getAllFailedFiles(userId, folderId).map { backupFiles ->
|
||||
BackupIssuesViewState(medias = backupFiles.map { backupFile ->
|
||||
backupFile.uriString.toUri()
|
||||
})
|
||||
}.catch { error ->
|
||||
broadcastMessages(
|
||||
userId = userId,
|
||||
message = error.getDefaultMessage(
|
||||
appContext,
|
||||
configurationProvider.useExceptionMessage
|
||||
),
|
||||
type = BroadcastMessage.Type.ERROR,
|
||||
)
|
||||
}
|
||||
|
||||
fun viewEvent(
|
||||
navigateBack: () -> Unit,
|
||||
navigateToSkipIssues: () -> Unit,
|
||||
): BackupIssuesViewEvent = object : BackupIssuesViewEvent {
|
||||
override val onBack = navigateBack
|
||||
override val onRetryAll = { onRetryAll(onSuccess = navigateBack) }
|
||||
override val onSkip = { navigateToSkipIssues() }
|
||||
}
|
||||
|
||||
private fun onRetryAll(onSuccess: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
retryBackup(userId).onSuccess {
|
||||
onSuccess()
|
||||
}.onFailure { error ->
|
||||
error.log(BACKUP, "Cannot retry on backup")
|
||||
broadcastMessages(
|
||||
userId = userId,
|
||||
message = error.getDefaultMessage(
|
||||
appContext,
|
||||
configurationProvider.useExceptionMessage
|
||||
),
|
||||
type = BroadcastMessage.Type.ERROR,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-7
@@ -24,7 +24,6 @@ import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.flow.transform
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -33,7 +32,6 @@ import me.proton.android.drive.ui.viewevent.ConfirmDeletionViewEvent
|
||||
import me.proton.android.drive.ui.viewstate.ConfirmDeletionViewState
|
||||
import me.proton.core.domain.arch.mapSuccessValueOrNull
|
||||
import me.proton.core.drive.base.domain.extension.filterSuccessOrError
|
||||
import me.proton.core.drive.base.domain.extension.toResult
|
||||
import me.proton.core.drive.base.presentation.extension.require
|
||||
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
|
||||
import me.proton.core.drive.drivelink.crypto.domain.usecase.GetDecryptedDriveLink
|
||||
@@ -50,28 +48,26 @@ class ConfirmDeletionDialogViewModel @Inject constructor(
|
||||
private val getDriveLink: GetDecryptedDriveLink,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
|
||||
|
||||
val shareId = ShareId(userId, savedStateHandle.require(Screen.Files.Dialogs.ConfirmDeletion.SHARE_ID))
|
||||
val linkId: LinkId = FileId(shareId, savedStateHandle.require(Screen.Files.Dialogs.ConfirmDeletion.FILE_ID))
|
||||
|
||||
val initialViewState = ConfirmDeletionViewState(null)
|
||||
fun viewState(dismiss: () -> Unit) = getDriveLink(linkId, failOnDecryptionError = false)
|
||||
val viewState = getDriveLink(linkId, failOnDecryptionError = false)
|
||||
.filterSuccessOrError()
|
||||
.mapSuccessValueOrNull()
|
||||
.transform { driveLink ->
|
||||
if (driveLink == null) {
|
||||
dismiss()
|
||||
return@transform
|
||||
}
|
||||
emit(ConfirmDeletionViewState(driveLink.name))
|
||||
}
|
||||
.shareIn(viewModelScope, SharingStarted.Eagerly)
|
||||
|
||||
fun viewEvent(dismiss: () -> Unit) = object : ConfirmDeletionViewEvent {
|
||||
fun viewEvent(onDismiss: () -> Unit) = object : ConfirmDeletionViewEvent {
|
||||
override val onConfirm = {
|
||||
viewModelScope.launch {
|
||||
deleteFromTrash(userId, linkId)
|
||||
dismiss()
|
||||
onDismiss()
|
||||
}
|
||||
Unit
|
||||
}
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.ui.viewmodel
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.launch
|
||||
import me.proton.android.drive.ui.navigation.Screen
|
||||
import me.proton.android.drive.ui.viewevent.ConfirmSkipIssuesViewEvent
|
||||
import me.proton.core.drive.backup.domain.usecase.DeleteAllFailedFiles
|
||||
import me.proton.core.drive.base.data.extension.getDefaultMessage
|
||||
import me.proton.core.drive.base.data.extension.log
|
||||
import me.proton.core.drive.base.domain.log.LogTag.BACKUP
|
||||
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
|
||||
import me.proton.core.drive.base.domain.usecase.BroadcastMessages
|
||||
import me.proton.core.drive.base.presentation.extension.require
|
||||
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
|
||||
import me.proton.core.drive.link.domain.entity.FolderId
|
||||
import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
|
||||
import me.proton.core.drive.share.domain.entity.ShareId
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@Suppress("StaticFieldLeak")
|
||||
class ConfirmSkipIssuesDialogViewModel @Inject constructor(
|
||||
@ApplicationContext private val appContext: Context,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val configurationProvider: ConfigurationProvider,
|
||||
private val broadcastMessages: BroadcastMessages,
|
||||
private val deleteAllFailedFiles: DeleteAllFailedFiles,
|
||||
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
|
||||
|
||||
val shareId = ShareId(userId, savedStateHandle.require(Screen.BackupIssues.SHARE_ID))
|
||||
val folderId: FolderId =
|
||||
FolderId(shareId, savedStateHandle.require(Screen.BackupIssues.FOLDER_ID))
|
||||
|
||||
fun viewEvent(
|
||||
onConfirm: () -> Unit,
|
||||
): ConfirmSkipIssuesViewEvent = object : ConfirmSkipIssuesViewEvent {
|
||||
override val onConfirm = { onSkipConfirmed(onSuccess = onConfirm) }
|
||||
}
|
||||
|
||||
private fun onSkipConfirmed(onSuccess: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
deleteAllFailedFiles(userId, folderId).onSuccess {
|
||||
onSuccess()
|
||||
}.onFailure { error ->
|
||||
error.log(BACKUP, "Cannot delete failed files")
|
||||
broadcastMessages(
|
||||
userId = userId,
|
||||
message = error.getDefaultMessage(
|
||||
appContext,
|
||||
configurationProvider.useExceptionMessage
|
||||
),
|
||||
type = BroadcastMessage.Type.ERROR,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+1
-1
@@ -33,9 +33,9 @@ import kotlinx.coroutines.launch
|
||||
import me.proton.android.drive.ui.navigation.Screen
|
||||
import me.proton.android.drive.ui.viewevent.ConfirmStopSharingViewEvent
|
||||
import me.proton.android.drive.ui.viewstate.ConfirmStopSharingViewState
|
||||
import me.proton.core.drive.base.data.extension.log
|
||||
import me.proton.core.drive.base.domain.log.LogTag.VIEW_MODEL
|
||||
import me.proton.core.drive.base.domain.usecase.BroadcastMessages
|
||||
import me.proton.core.drive.base.presentation.extension.log
|
||||
import me.proton.core.drive.base.presentation.extension.require
|
||||
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
|
||||
import me.proton.core.drive.drivelink.shared.domain.usecase.DeleteShareUrl
|
||||
|
||||
@@ -28,22 +28,25 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import me.proton.android.drive.ui.navigation.Screen
|
||||
import me.proton.android.drive.ui.viewstate.FileInfoViewState
|
||||
import me.proton.core.domain.arch.mapSuccess
|
||||
import me.proton.core.domain.arch.mapSuccessValueOrNull
|
||||
import me.proton.core.drive.base.domain.extension.asSuccess
|
||||
import me.proton.core.drive.base.domain.extension.toResult
|
||||
import me.proton.core.drive.base.presentation.extension.require
|
||||
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
|
||||
import me.proton.core.drive.crypto.domain.usecase.DecryptAncestorsName
|
||||
import me.proton.core.drive.drivelink.crypto.domain.usecase.GetDecryptedDriveLink
|
||||
import me.proton.core.drive.drivelink.domain.entity.DriveLink
|
||||
import me.proton.core.drive.file.info.presentation.extension.toItems
|
||||
import me.proton.core.drive.link.domain.entity.FileId
|
||||
import me.proton.core.drive.link.domain.entity.LinkId
|
||||
import me.proton.core.drive.link.presentation.extension.getName
|
||||
import me.proton.core.drive.share.domain.entity.ShareId
|
||||
import me.proton.core.drive.share.domain.usecase.GetShare
|
||||
import javax.inject.Inject
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
@@ -52,18 +55,34 @@ import javax.inject.Inject
|
||||
class FileInfoViewModel @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
getDriveLink: GetDecryptedDriveLink,
|
||||
getShare: GetShare,
|
||||
private val decryptAncestorsName: DecryptAncestorsName,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
|
||||
private val shareId: ShareId = ShareId(userId, savedStateHandle.require(Screen.Info.SHARE_ID))
|
||||
private val linkId: LinkId = FileId(shareId, savedStateHandle.require(Screen.Info.LINK_ID))
|
||||
|
||||
val viewState: Flow<FileInfoViewState?> =
|
||||
getDriveLink(linkId, failOnDecryptionError = false)
|
||||
.mapSuccess { (_, driveLink) ->
|
||||
FileInfoViewState(driveLink, driveLink.getParentPath()).asSuccess
|
||||
}.mapSuccessValueOrNull()
|
||||
.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
|
||||
private val driveLink = getDriveLink(linkId, failOnDecryptionError = false)
|
||||
.mapSuccessValueOrNull()
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||
private val share = getShare(shareId, flowOf(false))
|
||||
.mapSuccessValueOrNull()
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||
|
||||
val viewState: Flow<FileInfoViewState?> = combine(
|
||||
share.filterNotNull(),
|
||||
driveLink.filterNotNull(),
|
||||
) { share, driveLink ->
|
||||
FileInfoViewState(
|
||||
link = driveLink,
|
||||
items = driveLink.toItems(
|
||||
context = context,
|
||||
parentPath = driveLink.getParentPath(),
|
||||
capturedOn = (driveLink as? DriveLink.File)?.photoCaptureTime,
|
||||
shareType = share.type,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun DriveLink.getParentPath(): String =
|
||||
decryptAncestorsName(id).toResult().map { ancestors ->
|
||||
|
||||
+8
-5
@@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import me.proton.android.drive.ui.options.Option
|
||||
import me.proton.android.drive.ui.options.OptionsFilter
|
||||
import me.proton.android.drive.ui.options.filter
|
||||
import me.proton.android.drive.usecase.NotifyActivityNotFound
|
||||
import me.proton.core.domain.arch.mapSuccessValueOrNull
|
||||
@@ -71,6 +72,7 @@ class FileOrFolderOptionsViewModel @Inject constructor(
|
||||
new
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||
private val optionsFilter = savedStateHandle.require<OptionsFilter>(OPTIONS_FILTER)
|
||||
|
||||
fun <T : DriveLink> entries(
|
||||
driveLink: DriveLink,
|
||||
@@ -87,6 +89,7 @@ class FileOrFolderOptionsViewModel @Inject constructor(
|
||||
): List<FileOptionEntry<T>> =
|
||||
options
|
||||
.filter(driveLink)
|
||||
.filter(optionsFilter)
|
||||
.map { option ->
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
when (option) {
|
||||
@@ -96,7 +99,6 @@ class FileOrFolderOptionsViewModel @Inject constructor(
|
||||
is Option.OfflineToggle -> option.build(runAction) { driveLink ->
|
||||
viewModelScope.launch {
|
||||
toggleOffline(driveLink)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
is Option.Rename -> option.build(runAction, navigateToRename)
|
||||
@@ -105,7 +107,6 @@ class FileOrFolderOptionsViewModel @Inject constructor(
|
||||
toggleTrash = {
|
||||
viewModelScope.launch {
|
||||
toggleTrashState(driveLink)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -114,8 +115,9 @@ class FileOrFolderOptionsViewModel @Inject constructor(
|
||||
showCreateDocumentPicker(filename) { handleActivityNotFound() }
|
||||
}
|
||||
is Option.CopySharedLink -> option.build(runAction) { linkId ->
|
||||
copyPublicUrl(linkId)
|
||||
dismiss()
|
||||
viewModelScope.launch {
|
||||
copyPublicUrl(driveLink.volumeId, linkId)
|
||||
}
|
||||
}
|
||||
is Option.ShareViaLink -> option.build(runAction, navigateToShareViaLink)
|
||||
is Option.StopSharing -> option.build(runAction, navigateToStopSharing)
|
||||
@@ -140,6 +142,7 @@ class FileOrFolderOptionsViewModel @Inject constructor(
|
||||
companion object {
|
||||
const val KEY_SHARE_ID = "shareId"
|
||||
const val KEY_LINK_ID = "linkId"
|
||||
const val OPTIONS_FILTER = "optionsFilter"
|
||||
|
||||
private val options = setOf(
|
||||
Option.OfflineToggle,
|
||||
@@ -149,9 +152,9 @@ class FileOrFolderOptionsViewModel @Inject constructor(
|
||||
Option.Download,
|
||||
Option.Move,
|
||||
Option.Rename,
|
||||
Option.Trash,
|
||||
Option.Info,
|
||||
Option.StopSharing,
|
||||
Option.Trash,
|
||||
Option.DeletePermanently,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,14 +21,11 @@ package me.proton.android.drive.ui.viewmodel
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
@@ -52,15 +49,15 @@ import me.proton.android.drive.ui.effect.HomeTabViewModel
|
||||
import me.proton.android.drive.ui.navigation.Screen
|
||||
import me.proton.android.drive.usecase.OnFilesDriveLinkError
|
||||
import me.proton.core.domain.arch.onSuccess
|
||||
import me.proton.core.drive.base.data.extension.log
|
||||
import me.proton.core.drive.base.domain.entity.Percentage
|
||||
import me.proton.core.drive.base.domain.extension.filterSuccessOrError
|
||||
import me.proton.core.drive.base.domain.extension.mapWithPrevious
|
||||
import me.proton.core.drive.base.domain.extension.onFailure
|
||||
import me.proton.core.drive.base.domain.log.LogTag.VIEW_MODEL
|
||||
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
|
||||
import me.proton.core.drive.base.presentation.extension.log
|
||||
import me.proton.core.drive.base.presentation.common.Action
|
||||
import me.proton.core.drive.base.presentation.extension.quantityString
|
||||
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
|
||||
import me.proton.core.drive.drivelink.crypto.domain.usecase.GetDecryptedDriveLink
|
||||
import me.proton.core.drive.drivelink.domain.entity.DriveLink
|
||||
import me.proton.core.drive.drivelink.domain.extension.isNameEncrypted
|
||||
@@ -110,14 +107,14 @@ class FilesViewModel @Inject constructor(
|
||||
private val getUploadFileLinks: GetUploadFileLinks,
|
||||
private val getUploadProgress: GetUploadProgress,
|
||||
private val onFilesDriveLinkError: OnFilesDriveLinkError,
|
||||
private val selectLinks: SelectLinks,
|
||||
private val selectAll: SelectAll,
|
||||
private val deselectLinks: DeselectLinks,
|
||||
private val getSelectedDriveLinks: GetSelectedDriveLinks,
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
selectLinks: SelectLinks,
|
||||
selectAll: SelectAll,
|
||||
deselectLinks: DeselectLinks,
|
||||
getSelectedDriveLinks: GetSelectedDriveLinks,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
getSorting: GetSorting,
|
||||
private val configurationProvider: ConfigurationProvider,
|
||||
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle), HomeTabViewModel {
|
||||
) : SelectionViewModel(savedStateHandle, selectLinks, deselectLinks, selectAll, getSelectedDriveLinks), HomeTabViewModel {
|
||||
|
||||
private val shareId = savedStateHandle.get<String>(Screen.Files.SHARE_ID)
|
||||
private val folderId = savedStateHandle.get<String>(Screen.Files.FOLDER_ID)?.let { folderId ->
|
||||
@@ -132,6 +129,7 @@ class FilesViewModel @Inject constructor(
|
||||
result
|
||||
.onSuccess { driveLink ->
|
||||
CoreLogger.d(VIEW_MODEL, "drive link onSuccess")
|
||||
parentFolderId.value = driveLink.id
|
||||
return@mapWithPrevious driveLink
|
||||
}
|
||||
.onFailure { error ->
|
||||
@@ -162,44 +160,16 @@ class FilesViewModel @Inject constructor(
|
||||
private val _listEffect = MutableSharedFlow<ListEffect>()
|
||||
private val _homeEffect = MutableSharedFlow<HomeEffect>()
|
||||
private val layoutType = getLayoutType(userId).stateIn(viewModelScope, SharingStarted.Eagerly, LayoutType.DEFAULT)
|
||||
private val selectionId = MutableStateFlow(savedStateHandle.get<String?>(KEY_SELECTION_ID)?.let { SelectionId(it) })
|
||||
private val addFilesAction = FilesViewState.Action(
|
||||
private val addFilesAction = Action(
|
||||
iconResId = CorePresentation.drawable.ic_proton_plus,
|
||||
contentDescriptionResId = I18N.string.content_description_files_upload_upload_file,
|
||||
onAction = { viewEvent?.onParentFolderOptions?.invoke() },
|
||||
)
|
||||
private val selectAllAction = FilesViewState.Action(
|
||||
iconResId = CorePresentation.drawable.ic_proton_check_triple,
|
||||
contentDescriptionResId = I18N.string.content_description_select_all,
|
||||
onAction = {
|
||||
viewModelScope.launch {
|
||||
driveLink.value?.let { parent ->
|
||||
selectAll(parent.id, selectionId.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
private val selectedOptionsAction = FilesViewState.Action(
|
||||
iconResId = CorePresentation.drawable.ic_proton_three_dots_vertical,
|
||||
contentDescriptionResId = I18N.string.content_description_selected_options,
|
||||
onAction = { viewEvent?.onSelectedOptions?.invoke() }
|
||||
)
|
||||
private val topBarActions: MutableStateFlow<Set<FilesViewState.Action>> = MutableStateFlow(setOf(addFilesAction))
|
||||
private val selected: StateFlow<Set<LinkId>> = selectionId
|
||||
.filterNotNull()
|
||||
.transformLatest { id ->
|
||||
val parentId = driveLink.filterNotNull().first().id
|
||||
emitAll(
|
||||
getSelectedDriveLinks(id, parentId).map { driveLinks ->
|
||||
if (driveLinks.isEmpty()) {
|
||||
topBarActions.value = setOf(addFilesAction)
|
||||
} else {
|
||||
topBarActions.value = setOf(selectAllAction, selectedOptionsAction)
|
||||
}
|
||||
driveLinks.map { driveLink -> driveLink.id }.toSet()
|
||||
}
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptySet())
|
||||
|
||||
private val selectedOptionsAction get() = selectedOptionsAction {
|
||||
viewEvent?.onSelectedOptions?.invoke()
|
||||
}
|
||||
private val topBarActions: MutableStateFlow<Set<Action>> = MutableStateFlow(setOf(addFilesAction))
|
||||
val isBottomNavigationEnabled: Flow<Boolean> = selected.map { set -> set.isEmpty() }
|
||||
val initialViewState = FilesViewState(
|
||||
title = savedStateHandle[Screen.Files.FOLDER_NAME],
|
||||
@@ -226,6 +196,11 @@ class FilesViewModel @Inject constructor(
|
||||
layoutType,
|
||||
selected,
|
||||
) { driveLink, sorting, contentState, appendingState, layoutType, selected ->
|
||||
if (selected.isEmpty()) {
|
||||
topBarActions.value = setOf(addFilesAction)
|
||||
} else {
|
||||
topBarActions.value = setOf(selectAllAction, selectedOptionsAction)
|
||||
}
|
||||
initialViewState.copy(
|
||||
title = if (selected.isNotEmpty()) {
|
||||
appContext.quantityString(
|
||||
@@ -283,28 +258,17 @@ class FilesViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override val onTopAppBarNavigation = {
|
||||
if (selected.value.isNotEmpty()) {
|
||||
selectionId.value?.let { viewModelScope.launch { deselectLinks(it) } }
|
||||
override val onTopAppBarNavigation = onTopAppBarNavigation {
|
||||
if (isRootFolder) {
|
||||
viewModelScope.launch { _homeEffect.emit(HomeEffect.OpenDrawer) }
|
||||
Unit
|
||||
} else {
|
||||
if (isRootFolder) {
|
||||
viewModelScope.launch { _homeEffect.emit(HomeEffect.OpenDrawer) }
|
||||
Unit
|
||||
} else {
|
||||
navigateBack()
|
||||
}
|
||||
navigateBack()
|
||||
}
|
||||
}
|
||||
override val onSorting = navigateToSortingDialog
|
||||
override val onDriveLink = { driveLink: DriveLink ->
|
||||
if (selected.value.isNotEmpty()) {
|
||||
if (selected.value.contains(driveLink.id)) {
|
||||
removeSelected(listOf(driveLink.id))
|
||||
} else {
|
||||
addSelected(listOf(driveLink.id))
|
||||
}
|
||||
} else {
|
||||
onDriveLink(driveLink) {
|
||||
driveLinkShareFlow.tryEmit(driveLink)
|
||||
Unit
|
||||
}
|
||||
@@ -324,47 +288,20 @@ class FilesViewModel @Inject constructor(
|
||||
override val onErrorAction = { retry() }
|
||||
override val onAppendErrorAction = { retry() }
|
||||
override val onMoreOptions = { driveLink: DriveLink -> navigateToFileOrFolderOptions(driveLink.id) }
|
||||
override val onSelectedOptions = { onSelectedOptions(navigateToFileOrFolderOptions, navigateToMultipleFileOrFolderOptions) }
|
||||
override val onSelectedOptions = {
|
||||
onSelectedOptions(navigateToFileOrFolderOptions, navigateToMultipleFileOrFolderOptions)
|
||||
}
|
||||
override val onParentFolderOptions = { onParentFolderOptions(navigateToParentFolderOptions) }
|
||||
override val onCancelUpload = { uploadFileLink: UploadFileLink -> onCancelUpload(uploadFileLink) }
|
||||
override val onAddFiles = { onParentFolderOptions(navigateToParentFolderOptions) }
|
||||
override val onToggleLayout = this@FilesViewModel::onToggleLayout
|
||||
override val onSelectDriveLink = { driveLink: DriveLink -> addSelected(listOf(driveLink.id)) }
|
||||
override val onDeselectDriveLink = { driveLink: DriveLink -> removeSelected(listOf(driveLink.id)) }
|
||||
override val onBack = { removeAllSelected() }
|
||||
override val onSelectDriveLink = { driveLink: DriveLink -> onSelectDriveLink(driveLink) }
|
||||
override val onDeselectDriveLink = { driveLink: DriveLink -> onDeselectDriveLink(driveLink) }
|
||||
override val onBack = { onBack() }
|
||||
}.also { viewEvent ->
|
||||
this.viewEvent = viewEvent
|
||||
}
|
||||
|
||||
fun addSelected(linkIds: List<LinkId>) {
|
||||
viewModelScope.launch {
|
||||
selectionId.value?.let { selectionId ->
|
||||
selectLinks(selectionId, linkIds)
|
||||
} ?: setSelectionId(selectLinks(linkIds).getOrNull())
|
||||
}
|
||||
}
|
||||
|
||||
fun removeSelected(linkIds: List<LinkId>) {
|
||||
viewModelScope.launch {
|
||||
selectionId.value?.let { selectionId ->
|
||||
deselectLinks(selectionId, linkIds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeAllSelected() {
|
||||
if (selected.value.isNotEmpty()) {
|
||||
viewModelScope.launch {
|
||||
selectionId.value?.let { selectionId -> deselectLinks(selectionId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setSelectionId(selectionId: SelectionId?) {
|
||||
this.selectionId.value = selectionId
|
||||
savedStateHandle[KEY_SELECTION_ID] = selectionId?.id
|
||||
}
|
||||
|
||||
fun getDownloadProgressFlow(link: DriveLink): Flow<Percentage>? =
|
||||
if (link is DriveLink.File) {
|
||||
getDownloadProgress(link)
|
||||
@@ -410,33 +347,9 @@ class FilesViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSelectedOptions(
|
||||
navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit,
|
||||
navigateToMultipleFileOrFolderOptions: (selectionId: SelectionId) -> Unit,
|
||||
) {
|
||||
if (selected.value.size == 1) {
|
||||
navigateToFileOrFolderOptions(selected.value.first())
|
||||
} else {
|
||||
selectionId.value?.let { selectionId -> navigateToMultipleFileOrFolderOptions(selectionId) }
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
_listEffect.emit(ListEffect.REFRESH)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
selectionId.value?.let {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
deselectLinks(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_SELECTION_ID = "key.selectionId"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,16 +26,25 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import me.proton.android.drive.BuildConfig
|
||||
import me.proton.android.drive.ui.navigation.HomeTab
|
||||
import me.proton.android.drive.ui.navigation.Screen
|
||||
import me.proton.android.drive.ui.viewevent.HomeViewEvent
|
||||
import me.proton.android.drive.ui.viewstate.HomeViewState
|
||||
import me.proton.core.domain.entity.SessionUserId
|
||||
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
|
||||
import me.proton.core.drive.base.presentation.component.NavigationTab
|
||||
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
|
||||
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId
|
||||
import me.proton.core.drive.feature.flag.domain.usecase.IsFeatureFlagEnabled
|
||||
import me.proton.core.drive.navigationdrawer.presentation.NavigationDrawerViewEvent
|
||||
import me.proton.core.drive.navigationdrawer.presentation.NavigationDrawerViewState
|
||||
import me.proton.core.user.domain.UserManager
|
||||
@@ -49,24 +58,47 @@ import me.proton.core.presentation.R as CorePresentation
|
||||
class HomeViewModel @Inject constructor(
|
||||
userManager: UserManager,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
configurationProvider: ConfigurationProvider,
|
||||
isFeatureFlagEnabled: IsFeatureFlagEnabled,
|
||||
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
|
||||
|
||||
private val tabs: Map<HomeTab, NavigationTab> = mapOf(
|
||||
Screen.Files to NavigationTab(CorePresentation.drawable.ic_proton_folder_filled, I18N.string.title_files),
|
||||
Screen.Shared to NavigationTab(CorePresentation.drawable.ic_proton_link, I18N.string.title_shared)
|
||||
)
|
||||
private val isPhotosFeatureEnabled: StateFlow<Boolean?> = flow {
|
||||
emit(configurationProvider.photosFeatureFlag && isFeatureFlagEnabled(FeatureFlagId.drivePhotos(userId)))
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||
|
||||
private val tabs: StateFlow<Map<out HomeTab, NavigationTab>> = isPhotosFeatureEnabled
|
||||
.filterNotNull()
|
||||
.map { isPhotosFeatureEnabled ->
|
||||
listOfNotNull(
|
||||
Screen.Files to NavigationTab(
|
||||
iconResId = CorePresentation.drawable.ic_proton_folder,
|
||||
titleResId = I18N.string.title_files
|
||||
),
|
||||
takeIf { isPhotosFeatureEnabled }?.let {
|
||||
Screen.Photos to NavigationTab(
|
||||
iconResId = CorePresentation.drawable.ic_proton_image,
|
||||
titleResId = I18N.string.photos_title
|
||||
)
|
||||
},
|
||||
Screen.Shared to NavigationTab(
|
||||
iconResId = CorePresentation.drawable.ic_proton_link,
|
||||
titleResId = I18N.string.title_shared
|
||||
),
|
||||
).associateBy({ tab -> tab.first }, { tab -> tab.second })
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
private val currentDestination = MutableStateFlow(Screen.Files.route)
|
||||
fun setCurrentDestination(route: String) {
|
||||
currentDestination.value = route
|
||||
}
|
||||
|
||||
val initialViewState = getViewState(null, currentDestination.value)
|
||||
val viewState: Flow<HomeViewState> =
|
||||
combine(
|
||||
userManager.observeUser(SessionUserId(userId.id)),
|
||||
currentDestination
|
||||
) { user, selectedScreen ->
|
||||
getViewState(user, selectedScreen)
|
||||
currentDestination,
|
||||
tabs.filter { tabs -> tabs.isNotEmpty() },
|
||||
) { user, selectedScreen, tabs ->
|
||||
getViewState(user, selectedScreen, tabs)
|
||||
}.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
|
||||
|
||||
fun viewEvent(
|
||||
@@ -92,11 +124,12 @@ class HomeViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private val NavigationTab.screen: HomeTab
|
||||
get() = tabs.firstNotNullOf { (screen, value) -> screen.takeIf { value == this } }
|
||||
get() = tabs.value.firstNotNullOf { (screen, value) -> screen.takeIf { value == this } }
|
||||
|
||||
private fun getViewState(
|
||||
user: User?,
|
||||
startDestination: String,
|
||||
tabs: Map<out HomeTab, NavigationTab>,
|
||||
) =
|
||||
HomeViewState(
|
||||
tabs = tabs.values.toList(),
|
||||
|
||||
+6
-2
@@ -26,7 +26,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
import me.proton.android.drive.ui.options.Option
|
||||
import me.proton.android.drive.ui.options.OptionsFilter
|
||||
import me.proton.android.drive.ui.options.filter
|
||||
import me.proton.android.drive.ui.options.filterAll
|
||||
import me.proton.core.drive.base.presentation.extension.require
|
||||
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
|
||||
import me.proton.core.drive.documentsprovider.domain.usecase.ExportToDownload
|
||||
import me.proton.core.drive.drivelink.domain.entity.DriveLink
|
||||
@@ -50,6 +53,7 @@ class MultipleFileOrFolderOptionsViewModel @Inject constructor(
|
||||
val selectedDriveLinks: Flow<List<DriveLink>> = getSelectedDriveLinks(selectionId)
|
||||
// Send -> ACTION_SEND_MULTIPLE (mime type aggregation) - we need to update sendfiledialog with multiple file download
|
||||
// Mime type aggregation - all the same use that one, all same prefix use prefix/* else use */*
|
||||
private val optionsFilter = savedStateHandle.require<OptionsFilter>(OPTIONS_FILTER)
|
||||
fun entries(
|
||||
driveLinks: List<DriveLink>,
|
||||
runAction: (suspend () -> Unit) -> Unit,
|
||||
@@ -58,6 +62,7 @@ class MultipleFileOrFolderOptionsViewModel @Inject constructor(
|
||||
): List<OptionEntry<Unit>> =
|
||||
options
|
||||
.filterAll(driveLinks)
|
||||
.filter(optionsFilter)
|
||||
.map { option ->
|
||||
when (option) {
|
||||
is Option.Trash -> option.build(
|
||||
@@ -66,7 +71,6 @@ class MultipleFileOrFolderOptionsViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
sendToTrash(userId, driveLinks)
|
||||
deselectLinks(selectionId)
|
||||
dismiss()
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -84,7 +88,6 @@ class MultipleFileOrFolderOptionsViewModel @Inject constructor(
|
||||
driveLinks.filterIsInstance<DriveLink.File>().map { driveLink -> driveLink.id }
|
||||
)
|
||||
deselectLinks(selectionId)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -99,6 +102,7 @@ class MultipleFileOrFolderOptionsViewModel @Inject constructor(
|
||||
|
||||
companion object {
|
||||
const val KEY_SELECTION_ID = "selectionId"
|
||||
const val OPTIONS_FILTER = "optionsFilter"
|
||||
|
||||
private val options = setOfNotNull(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) Option.Download else null,
|
||||
|
||||
@@ -47,12 +47,12 @@ import me.proton.android.drive.ui.navigation.Screen
|
||||
import me.proton.android.drive.ui.viewevent.OfflineViewEvent
|
||||
import me.proton.android.drive.ui.viewstate.OfflineViewState
|
||||
import me.proton.core.domain.arch.onSuccess
|
||||
import me.proton.core.drive.base.data.extension.log
|
||||
import me.proton.core.drive.base.domain.entity.Percentage
|
||||
import me.proton.core.drive.base.domain.entity.onProcessing
|
||||
import me.proton.core.drive.base.domain.extension.onFailure
|
||||
import me.proton.core.drive.base.domain.log.LogTag.VIEW_MODEL
|
||||
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
|
||||
import me.proton.core.drive.base.presentation.extension.log
|
||||
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
|
||||
import me.proton.core.drive.drivelink.crypto.domain.usecase.GetDecryptedDriveLink
|
||||
import me.proton.core.drive.drivelink.domain.entity.DriveLink
|
||||
|
||||
+3
@@ -48,6 +48,7 @@ import me.proton.core.drive.drivelink.upload.domain.usecase.UploadFiles
|
||||
import me.proton.core.drive.files.presentation.entry.FileOptionEntry
|
||||
import me.proton.core.drive.link.domain.entity.FolderId
|
||||
import me.proton.core.drive.link.domain.extension.userId
|
||||
import me.proton.core.drive.linkupload.domain.entity.UploadFileLink
|
||||
import me.proton.core.drive.share.domain.entity.ShareId
|
||||
import me.proton.core.drive.upload.domain.exception.NotEnoughSpaceException
|
||||
import me.proton.core.util.kotlin.CoreLogger
|
||||
@@ -116,6 +117,7 @@ class ParentFolderOptionsViewModel @Inject constructor(
|
||||
uploadFiles(
|
||||
folder = folder,
|
||||
uriStrings = uriStrings,
|
||||
priority = UploadFileLink.USER_PRIORITY,
|
||||
)
|
||||
.onFailure { error ->
|
||||
if (error is NotEnoughSpaceException) {
|
||||
@@ -137,6 +139,7 @@ class ParentFolderOptionsViewModel @Inject constructor(
|
||||
folder = folder,
|
||||
uriStrings = listOf(uri.toString()),
|
||||
shouldDeleteSource = true,
|
||||
priority = UploadFileLink.USER_PRIORITY,
|
||||
)
|
||||
.onFailure { error ->
|
||||
if (error is NotEnoughSpaceException) {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.ui.viewmodel
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import me.proton.android.drive.photos.domain.entity.PhotoBackupState
|
||||
import me.proton.android.drive.photos.presentation.viewmodel.BackupPermissionsViewModel
|
||||
import me.proton.android.drive.ui.viewevent.PhotosBackupViewEvent
|
||||
import me.proton.android.drive.ui.viewstate.PhotosBackupViewState
|
||||
import me.proton.core.drive.backup.domain.manager.BackupManager
|
||||
import me.proton.core.drive.base.domain.usecase.BroadcastMessages
|
||||
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
|
||||
import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
|
||||
import javax.inject.Inject
|
||||
import me.proton.core.drive.i18n.R as I18N
|
||||
|
||||
@Suppress("StaticFieldLeak")
|
||||
@HiltViewModel
|
||||
class PhotosBackupViewModel @Inject constructor(
|
||||
@ApplicationContext private val appContext: Context,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
backupManager: BackupManager,
|
||||
private val broadcastMessages: BroadcastMessages,
|
||||
val backupPermissionsViewModel: BackupPermissionsViewModel,
|
||||
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
|
||||
|
||||
val initialViewState: PhotosBackupViewState = PhotosBackupViewState(
|
||||
title = appContext.getString(I18N.string.photos_backup_title),
|
||||
enableBackupTitle = appContext.getString(I18N.string.photos_backup_title),
|
||||
isBackupEnabled = false,
|
||||
)
|
||||
val viewState: Flow<PhotosBackupViewState> = backupManager.isEnabled(userId).map { enabled ->
|
||||
initialViewState.copy(
|
||||
isBackupEnabled = enabled,
|
||||
)
|
||||
}
|
||||
|
||||
fun viewEvent(
|
||||
navigateBack: () -> Unit,
|
||||
): PhotosBackupViewEvent = object : PhotosBackupViewEvent {
|
||||
override val onToggle = {
|
||||
backupPermissionsViewModel.toggleBackup(userId) { state ->
|
||||
with(state) {
|
||||
broadcastMessages(
|
||||
userId = userId,
|
||||
message = message,
|
||||
type = type,
|
||||
)
|
||||
action()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val PhotoBackupState.message
|
||||
get() = when (this) {
|
||||
PhotoBackupState.Disabled -> appContext.getString(
|
||||
I18N.string.photos_backup_in_app_notification_turned_off
|
||||
)
|
||||
|
||||
is PhotoBackupState.Enabled -> appContext.getString(
|
||||
I18N.string.photos_backup_in_app_notification_turned_on
|
||||
)
|
||||
|
||||
is PhotoBackupState.NoFolder -> appContext.getString(
|
||||
I18N.string.photos_error_no_folders
|
||||
).format(folderName)
|
||||
}
|
||||
|
||||
private val PhotoBackupState.type
|
||||
get() = when (this) {
|
||||
PhotoBackupState.Disabled -> BroadcastMessage.Type.INFO
|
||||
is PhotoBackupState.Enabled -> BroadcastMessage.Type.INFO
|
||||
is PhotoBackupState.NoFolder -> BroadcastMessage.Type.WARNING
|
||||
}
|
||||
|
||||
private val PhotoBackupState.action: () -> Unit
|
||||
get() = when (this) {
|
||||
PhotoBackupState.Disabled -> navigateBack
|
||||
is PhotoBackupState.Enabled -> navigateBack
|
||||
is PhotoBackupState.NoFolder -> { -> }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.ui.viewmodel
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
import me.proton.android.drive.extension.getDefaultMessage
|
||||
import me.proton.android.drive.extension.log
|
||||
import me.proton.android.drive.ui.viewevent.PhotosExportDataViewEvent
|
||||
import me.proton.android.drive.ui.viewstate.PhotosExportDataViewState
|
||||
import me.proton.android.drive.usecase.ExportPhotoData
|
||||
import me.proton.android.drive.usecase.SendFile
|
||||
import me.proton.core.drive.backup.domain.manager.BackupManager
|
||||
import me.proton.core.drive.base.domain.log.LogTag.BACKUP
|
||||
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
|
||||
import me.proton.core.drive.base.domain.usecase.BroadcastMessages
|
||||
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
|
||||
import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
|
||||
import javax.inject.Inject
|
||||
|
||||
@Suppress("StaticFieldLeak", "LongParameterList")
|
||||
@HiltViewModel
|
||||
class PhotosExportDataViewModel @Inject constructor(
|
||||
@ApplicationContext private val appContext: Context,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
backupManager: BackupManager,
|
||||
private val broadcastMessages: BroadcastMessages,
|
||||
private val configurationProvider: ConfigurationProvider,
|
||||
private val exportPhotoData: ExportPhotoData,
|
||||
private val sendFile: SendFile,
|
||||
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
|
||||
|
||||
private val exportData = MutableStateFlow(false)
|
||||
|
||||
val initialViewState: PhotosExportDataViewState = PhotosExportDataViewState(
|
||||
isExportDataEnabled = false,
|
||||
isExportDataLoading = false
|
||||
)
|
||||
val viewState: Flow<PhotosExportDataViewState> =
|
||||
combine(backupManager.isEnabled(userId), exportData) { enabled, exportData ->
|
||||
initialViewState.copy(
|
||||
isExportDataEnabled = enabled && configurationProvider.photoExportData,
|
||||
isExportDataLoading = exportData,
|
||||
)
|
||||
}
|
||||
|
||||
fun viewEvent(): PhotosExportDataViewEvent = object : PhotosExportDataViewEvent {
|
||||
|
||||
override val onExportData = { context: Context ->
|
||||
viewModelScope.launch {
|
||||
exportData.value = true
|
||||
onExportData(context)
|
||||
exportData.value = false
|
||||
}
|
||||
Unit
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onExportData(context: Context) {
|
||||
val onFailure: (exception: Throwable) -> Unit = { error ->
|
||||
error.log(BACKUP)
|
||||
broadcastMessages(
|
||||
userId = userId,
|
||||
message = error.getDefaultMessage(
|
||||
appContext,
|
||||
configurationProvider.useExceptionMessage
|
||||
),
|
||||
type = BroadcastMessage.Type.ERROR,
|
||||
)
|
||||
}
|
||||
exportPhotoData(userId).onSuccess { file ->
|
||||
sendFile(context, file).onFailure(onFailure)
|
||||
}.onFailure(onFailure)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.ui.viewmodel
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.CombinedLoadStates
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import androidx.paging.insertSeparators
|
||||
import androidx.paging.map
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.take
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import me.proton.android.drive.photos.domain.entity.PhotoBackupState
|
||||
import me.proton.android.drive.photos.domain.usecase.EnablePhotosBackup
|
||||
import me.proton.android.drive.photos.domain.usecase.GetPhotosDriveLink
|
||||
import me.proton.android.drive.photos.presentation.R
|
||||
import me.proton.android.drive.photos.presentation.state.PhotosItem
|
||||
import me.proton.android.drive.photos.presentation.viewevent.PhotosViewEvent
|
||||
import me.proton.android.drive.photos.presentation.viewmodel.BackupPermissionsViewModel
|
||||
import me.proton.android.drive.photos.presentation.viewmodel.BackupStatusFormatter
|
||||
import me.proton.android.drive.photos.presentation.viewmodel.SeparatorFormatter
|
||||
import me.proton.android.drive.photos.presentation.viewstate.PhotosViewState
|
||||
import me.proton.android.drive.ui.common.onClick
|
||||
import me.proton.android.drive.ui.effect.HomeEffect
|
||||
import me.proton.android.drive.ui.effect.HomeTabViewModel
|
||||
import me.proton.android.drive.usecase.OnFilesDriveLinkError
|
||||
import me.proton.core.domain.arch.onSuccess
|
||||
import me.proton.core.drive.backup.domain.entity.BackupPermissions
|
||||
import me.proton.core.drive.backup.domain.entity.BackupStatus
|
||||
import me.proton.core.drive.backup.domain.manager.BackupPermissionsManager
|
||||
import me.proton.core.drive.backup.domain.usecase.GetBackupState
|
||||
import me.proton.core.drive.backup.domain.usecase.RetryBackup
|
||||
import me.proton.core.drive.base.data.extension.getDefaultMessage
|
||||
import me.proton.core.drive.base.data.extension.log
|
||||
import me.proton.core.drive.base.domain.extension.combine
|
||||
import me.proton.core.drive.base.domain.extension.filterSuccessOrError
|
||||
import me.proton.core.drive.base.domain.extension.mapWithPrevious
|
||||
import me.proton.core.drive.base.domain.extension.onFailure
|
||||
import me.proton.core.drive.base.domain.log.LogTag.BACKUP
|
||||
import me.proton.core.drive.base.domain.log.LogTag.VIEW_MODEL
|
||||
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
|
||||
import me.proton.core.drive.base.domain.usecase.BroadcastMessages
|
||||
import me.proton.core.drive.base.presentation.common.Action
|
||||
import me.proton.core.drive.base.presentation.extension.launchApplicationDetailsSettings
|
||||
import me.proton.core.drive.base.presentation.extension.quantityString
|
||||
import me.proton.core.drive.drivelink.domain.entity.DriveLink
|
||||
import me.proton.core.drive.drivelink.photo.domain.paging.PhotoDriveLinks
|
||||
import me.proton.core.drive.drivelink.photo.domain.usecase.GetPagedPhotoListingsList
|
||||
import me.proton.core.drive.drivelink.selection.domain.usecase.GetSelectedDriveLinks
|
||||
import me.proton.core.drive.drivelink.selection.domain.usecase.SelectAll
|
||||
import me.proton.core.drive.files.presentation.state.ListContentAppendingState
|
||||
import me.proton.core.drive.files.presentation.state.ListContentState
|
||||
import me.proton.core.drive.files.presentation.state.ListEffect
|
||||
import me.proton.core.drive.link.domain.entity.FileId
|
||||
import me.proton.core.drive.link.domain.entity.FolderId
|
||||
import me.proton.core.drive.link.domain.entity.LinkId
|
||||
import me.proton.core.drive.link.selection.domain.entity.SelectionId
|
||||
import me.proton.core.drive.link.selection.domain.usecase.DeselectLinks
|
||||
import me.proton.core.drive.link.selection.domain.usecase.SelectLinks
|
||||
import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
|
||||
import me.proton.core.drive.photo.domain.usecase.GetPhotoCount
|
||||
import me.proton.core.drive.share.domain.entity.Share
|
||||
import me.proton.core.util.kotlin.CoreLogger
|
||||
import java.util.Calendar
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import me.proton.core.drive.i18n.R as I18N
|
||||
import me.proton.core.presentation.R as CorePresentation
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
@Suppress("StaticFieldLeak", "LongParameterList")
|
||||
class PhotosViewModel @Inject constructor(
|
||||
@ApplicationContext private val appContext: Context,
|
||||
getPagedPhotoListingsList: GetPagedPhotoListingsList,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val separatorFormatter: SeparatorFormatter,
|
||||
private val backupStatusFormatter: BackupStatusFormatter,
|
||||
private val getPhotosDriveLink: GetPhotosDriveLink,
|
||||
private val enablePhotosBackup: EnablePhotosBackup,
|
||||
private val retryBackup: RetryBackup,
|
||||
private val backupPermissionsManager: BackupPermissionsManager,
|
||||
private val configurationProvider: ConfigurationProvider,
|
||||
private val broadcastMessages: BroadcastMessages,
|
||||
getBackupState: GetBackupState,
|
||||
getPhotoCount: GetPhotoCount,
|
||||
getSelectedDriveLinks: GetSelectedDriveLinks,
|
||||
selectAll: SelectAll,
|
||||
selectLinks: SelectLinks,
|
||||
deselectLinks: DeselectLinks,
|
||||
val backupPermissionsViewModel: BackupPermissionsViewModel,
|
||||
private val photoDriveLinks: PhotoDriveLinks,
|
||||
private val onFilesDriveLinkError: OnFilesDriveLinkError,
|
||||
) : SelectionViewModel(
|
||||
savedStateHandle, selectLinks, deselectLinks, selectAll, getSelectedDriveLinks
|
||||
), HomeTabViewModel {
|
||||
|
||||
private var viewEvent: PhotosViewEvent? = null
|
||||
private var fetchingJob: Job? = null
|
||||
private val selectedOptionsAction
|
||||
get() = selectedOptionsAction {
|
||||
viewEvent?.onSelectedOptions?.invoke()
|
||||
}
|
||||
private val topBarActions: MutableStateFlow<Set<Action>> = MutableStateFlow(emptySet())
|
||||
|
||||
private val listContentState = MutableStateFlow<ListContentState>(ListContentState.Loading)
|
||||
private val firstVisibleItemIndex = MutableStateFlow<Int?>(null)
|
||||
private val forceStatusExpand = MutableStateFlow(false)
|
||||
|
||||
val initialViewState = PhotosViewState(
|
||||
title = appContext.getString(I18N.string.photos_title),
|
||||
navigationIconResId = CorePresentation.drawable.ic_proton_hamburger,
|
||||
topBarActions = topBarActions,
|
||||
listContentState = ListContentState.Loading,
|
||||
isBackupEnabled = null,
|
||||
showPhotosStateIndicator = false,
|
||||
showPhotosStateBanner = false,
|
||||
backupStatusViewState = null,
|
||||
selected = selected,
|
||||
isRefreshEnabled = selected.value.isEmpty(),
|
||||
)
|
||||
private val retryTrigger = MutableSharedFlow<Unit>(replay = 1).apply { tryEmit(Unit) }
|
||||
val driveLink: StateFlow<DriveLink.Folder?> = retryTrigger.transformLatest {
|
||||
emitAll(
|
||||
getPhotosDriveLink(userId)
|
||||
.filterSuccessOrError()
|
||||
.mapWithPrevious { previous, result ->
|
||||
result
|
||||
.onSuccess { driveLink ->
|
||||
CoreLogger.d(VIEW_MODEL, "drive link onSuccess")
|
||||
parentFolderId.value = driveLink.id
|
||||
return@mapWithPrevious driveLink
|
||||
}
|
||||
.onFailure { error ->
|
||||
onFilesDriveLinkError(
|
||||
userId = userId,
|
||||
previous = previous,
|
||||
error = error,
|
||||
contentState = listContentState,
|
||||
shareType = Share.Type.PHOTO,
|
||||
)
|
||||
error.log(VIEW_MODEL, "Cannot get drive link")
|
||||
}
|
||||
return@mapWithPrevious null
|
||||
}
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||
|
||||
val driveLinksMap: Flow<Map<LinkId, DriveLink>> = photoDriveLinks.getDriveLinksMapFlow(userId)
|
||||
|
||||
val driveLinks: Flow<PagingData<PhotosItem>> =
|
||||
parentFolderId
|
||||
.filterNotNull()
|
||||
.distinctUntilChanged()
|
||||
.transformLatest { folderId ->
|
||||
emitAll(
|
||||
getPagedPhotoListingsList(userId)
|
||||
.map { pagingData ->
|
||||
pagingData.map { photoListing ->
|
||||
PhotosItem.PhotoListing(
|
||||
photoListing.linkId,
|
||||
photoListing.captureTime,
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
.map {
|
||||
it.insertSeparators { before: PhotosItem.PhotoListing?, after: PhotosItem.PhotoListing? ->
|
||||
if (after == null) {
|
||||
null
|
||||
} else if (before == null) {
|
||||
PhotosItem.Separator(
|
||||
value = separatorFormatter.toSeparator(after.captureTime),
|
||||
)
|
||||
} else {
|
||||
val beforeCalendar = Calendar.getInstance().apply {
|
||||
timeInMillis = before.captureTime.value * 1000L
|
||||
}
|
||||
val afterCalendar = Calendar.getInstance().apply {
|
||||
timeInMillis = after.captureTime.value * 1000L
|
||||
}
|
||||
if (beforeCalendar.get(Calendar.MONTH) != afterCalendar.get(
|
||||
Calendar.MONTH
|
||||
)
|
||||
) {
|
||||
PhotosItem.Separator(
|
||||
value = separatorFormatter.toSeparator(after.captureTime),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}.cachedIn(viewModelScope)
|
||||
)
|
||||
}
|
||||
|
||||
private val listContentAppendingState = MutableStateFlow<ListContentAppendingState>(
|
||||
ListContentAppendingState.Idle
|
||||
)
|
||||
|
||||
private val _homeEffect = MutableSharedFlow<HomeEffect>()
|
||||
override val homeEffect: Flow<HomeEffect>
|
||||
get() = _homeEffect.asSharedFlow()
|
||||
|
||||
private val _listEffect = MutableSharedFlow<ListEffect>()
|
||||
val listEffect: Flow<ListEffect>
|
||||
get() = _listEffect.asSharedFlow()
|
||||
|
||||
private val emptyState = ListContentState.Empty(
|
||||
imageResId = R.drawable.img_photos_no_backup_yet,
|
||||
titleId = I18N.string.photos_empty_title,
|
||||
descriptionResId = I18N.string.photos_empty_description,
|
||||
actionResId = 0,
|
||||
)
|
||||
|
||||
val viewState: Flow<PhotosViewState> = combine(
|
||||
selected,
|
||||
listContentState,
|
||||
getBackupState(userId = userId),
|
||||
getPhotoCount(userId = userId),
|
||||
firstVisibleItemIndex,
|
||||
forceStatusExpand,
|
||||
) { selected, listContentState, backupState, count, firstVisibleItemIndex, forceStatusExpand ->
|
||||
if (selected.isEmpty()) {
|
||||
topBarActions.value = emptySet()
|
||||
} else {
|
||||
topBarActions.value = setOf(selectAllAction, selectedOptionsAction)
|
||||
}
|
||||
val isDisableOrRunning = !backupState.isBackupEnabled
|
||||
|| backupState.backupStatus?.isRunning() == true
|
||||
val showPhotosStateBanner = isDisableOrRunning || forceStatusExpand
|
||||
val showPhotosStateIndicator = selected.isEmpty() &&
|
||||
((firstVisibleItemIndex?.let { index -> index > 0 } ?: false)
|
||||
|| !isDisableOrRunning)
|
||||
initialViewState.copy(
|
||||
title = if (selected.isNotEmpty()) {
|
||||
appContext.quantityString(
|
||||
I18N.plurals.common_selected,
|
||||
selected.size
|
||||
)
|
||||
} else {
|
||||
appContext.getString(I18N.string.photos_title)
|
||||
},
|
||||
navigationIconResId = if (selected.isNotEmpty()) {
|
||||
CorePresentation.drawable.ic_proton_cross
|
||||
} else {
|
||||
CorePresentation.drawable.ic_proton_hamburger
|
||||
},
|
||||
listContentState = listContentState,
|
||||
isBackupEnabled = backupState.isBackupEnabled,
|
||||
showPhotosStateIndicator = showPhotosStateIndicator,
|
||||
showPhotosStateBanner = showPhotosStateBanner,
|
||||
backupStatusViewState = backupStatusFormatter.toViewState(
|
||||
backupState = backupState,
|
||||
count = count.takeIf { configurationProvider.photosSavedCounter },
|
||||
),
|
||||
isRefreshEnabled = selected.isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
fun viewEvent(
|
||||
navigateToPreview: (fileId: FileId) -> Unit,
|
||||
navigateToPhotosOptions: (fileId: FileId) -> Unit,
|
||||
navigateToMultiplePhotosOptions: (selectionId: SelectionId) -> Unit,
|
||||
navigateToSubscription: () -> Unit,
|
||||
navigateToPhotosIssues: (FolderId) -> Unit,
|
||||
): PhotosViewEvent = object : PhotosViewEvent {
|
||||
|
||||
private val driveLinkShareFlow =
|
||||
MutableSharedFlow<DriveLink>(extraBufferCapacity = 1).also { flow ->
|
||||
viewModelScope.launch {
|
||||
flow.take(1).collect { driveLink ->
|
||||
driveLink.onClick({ _, _ -> }, navigateToPreview)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val onTopAppBarNavigation = onTopAppBarNavigation {
|
||||
viewModelScope.launch { _homeEffect.emit(HomeEffect.OpenDrawer) }
|
||||
Unit
|
||||
}
|
||||
override val onDriveLink = { driveLink: DriveLink ->
|
||||
onDriveLink(driveLink) {
|
||||
driveLinkShareFlow.tryEmit(driveLink)
|
||||
Unit
|
||||
}
|
||||
}
|
||||
override val onLoadState: (CombinedLoadStates, Int) -> Unit = onLoadState(
|
||||
appContext = appContext,
|
||||
useExceptionMessage = configurationProvider.useExceptionMessage,
|
||||
listContentState = listContentState,
|
||||
listAppendContentState = listContentAppendingState,
|
||||
coroutineScope = viewModelScope,
|
||||
emptyState = emptyState,
|
||||
) { message ->
|
||||
viewModelScope.launch {
|
||||
_homeEffect.emit(HomeEffect.ShowSnackbar(message))
|
||||
}
|
||||
}
|
||||
override val onRefresh = this@PhotosViewModel::onRefresh
|
||||
override val onErrorAction = this@PhotosViewModel::onErrorAction
|
||||
override val onSelectedOptions =
|
||||
{ onSelectedOptions(navigateToPhotosOptions, navigateToMultiplePhotosOptions) }
|
||||
override val onSelectDriveLink = { driveLink: DriveLink -> onSelectDriveLink(driveLink) }
|
||||
override val onDeselectDriveLink =
|
||||
{ driveLink: DriveLink -> onDeselectDriveLink(driveLink) }
|
||||
override val onBack = { onBack() }
|
||||
override val onEnable = this@PhotosViewModel::onEnable
|
||||
override val onPermissionsChanged = this@PhotosViewModel::onPermissionsChanged
|
||||
override val onPermissions = { appContext.launchApplicationDetailsSettings() }
|
||||
override val onRetry = this@PhotosViewModel::onRetry
|
||||
override val onScroll = this@PhotosViewModel::onScroll
|
||||
override val onStatusClicked = this@PhotosViewModel::onStatusClicked
|
||||
override val onGetStorage: () -> Unit = navigateToSubscription
|
||||
override val onResolve: () -> Unit = {
|
||||
parentFolderId.value?.let { folderId ->
|
||||
navigateToPhotosIssues(folderId)
|
||||
}
|
||||
}
|
||||
}.also { viewEvent ->
|
||||
this.viewEvent = viewEvent
|
||||
}
|
||||
|
||||
private fun onPermissionsChanged(backupPermissions: BackupPermissions) {
|
||||
viewModelScope.launch {
|
||||
if (backupPermissions == BackupPermissions.Granted) {
|
||||
val previousPhotosPermissions = backupPermissionsManager.backupPermissions.first()
|
||||
if (previousPhotosPermissions is BackupPermissions.Denied) {
|
||||
enablePhotosBackup()
|
||||
}
|
||||
}
|
||||
backupPermissionsManager.onPermissionChanged(backupPermissions)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onEnable() {
|
||||
backupPermissionsViewModel.toggleBackup(userId) { state ->
|
||||
onPhotoBackupState(state)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onScroll(firstVisibleItemIndex: Int, driveLinkIds: Set<LinkId>) {
|
||||
this.firstVisibleItemIndex.value = firstVisibleItemIndex
|
||||
if (driveLinkIds.isNotEmpty()) {
|
||||
fetchingJob?.cancel()
|
||||
fetchingJob = viewModelScope.launch {
|
||||
delay(300.milliseconds)
|
||||
photoDriveLinks.load(driveLinkIds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onStatusClicked() {
|
||||
forceStatusExpand.value = !forceStatusExpand.value
|
||||
}
|
||||
|
||||
private suspend fun enablePhotosBackup() {
|
||||
enablePhotosBackup(userId).onSuccess { state ->
|
||||
onPhotoBackupState(state)
|
||||
}.onFailure { error ->
|
||||
broadcastMessages(
|
||||
userId = userId,
|
||||
message = error.getDefaultMessage(
|
||||
appContext,
|
||||
configurationProvider.useExceptionMessage
|
||||
),
|
||||
type = BroadcastMessage.Type.ERROR,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onPhotoBackupState(state: PhotoBackupState) {
|
||||
when (state) {
|
||||
is PhotoBackupState.NoFolder -> {
|
||||
broadcastMessages(
|
||||
userId = userId,
|
||||
message = appContext
|
||||
.getString(I18N.string.photos_error_no_folders)
|
||||
.format(state.folderName),
|
||||
type = BroadcastMessage.Type.WARNING,
|
||||
)
|
||||
}
|
||||
|
||||
is PhotoBackupState.Enabled -> {
|
||||
broadcastMessages(
|
||||
userId = userId,
|
||||
message = appContext.resources
|
||||
.getQuantityString(
|
||||
I18N.plurals.photos_message_folders_setup,
|
||||
state.backupFolders.size
|
||||
)
|
||||
.format(state.folderName, state.backupFolders.size),
|
||||
type = BroadcastMessage.Type.INFO,
|
||||
)
|
||||
}
|
||||
|
||||
PhotoBackupState.Disabled -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun onRetry() {
|
||||
viewModelScope.launch {
|
||||
retryBackup(userId).onFailure { error ->
|
||||
error.log(BACKUP, "Cannot retry on backup")
|
||||
broadcastMessages(
|
||||
userId = userId,
|
||||
message = error.getDefaultMessage(
|
||||
appContext,
|
||||
configurationProvider.useExceptionMessage
|
||||
),
|
||||
type = BroadcastMessage.Type.ERROR,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun BackupStatus.isRunning(): Boolean = when (this) {
|
||||
is BackupStatus.Complete -> totalBackupPhotos > 0
|
||||
else -> true
|
||||
}
|
||||
|
||||
private fun onErrorAction() {
|
||||
viewModelScope.launch {
|
||||
if (driveLink.value == null) {
|
||||
retryLoadingPhotosDriveLinkFolder()
|
||||
} else {
|
||||
retryList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun retryLoadingPhotosDriveLinkFolder() {
|
||||
retryTrigger.emit(Unit)
|
||||
listContentState.value = ListContentState.Loading
|
||||
}
|
||||
|
||||
private suspend fun retryList() {
|
||||
_listEffect.emit(ListEffect.RETRY)
|
||||
}
|
||||
|
||||
private fun onRefresh() {
|
||||
viewModelScope.launch {
|
||||
_listEffect.emit(ListEffect.REFRESH)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@
|
||||
|
||||
package me.proton.android.drive.ui.viewmodel
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
@@ -46,6 +45,7 @@ import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.transform
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import me.proton.android.drive.extension.getDefaultMessage
|
||||
@@ -55,18 +55,25 @@ import me.proton.android.drive.ui.navigation.Screen
|
||||
import me.proton.core.domain.arch.mapSuccessValueOrNull
|
||||
import me.proton.core.domain.arch.transformSuccess
|
||||
import me.proton.core.domain.entity.UserId
|
||||
import me.proton.core.drive.base.data.extension.log
|
||||
import me.proton.core.drive.base.domain.entity.Attributes
|
||||
import me.proton.core.drive.base.domain.entity.CryptoProperty
|
||||
import me.proton.core.drive.base.domain.entity.Permissions
|
||||
import me.proton.core.drive.base.domain.entity.TimestampS
|
||||
import me.proton.core.drive.base.domain.entity.toFileTypeCategory
|
||||
import me.proton.core.drive.base.domain.extension.bytes
|
||||
import me.proton.core.drive.base.domain.extension.filterSuccessOrError
|
||||
import me.proton.core.drive.base.domain.function.pagedList
|
||||
import me.proton.core.drive.base.domain.log.LogTag
|
||||
import me.proton.core.drive.base.domain.log.LogTag.VIEW_MODEL
|
||||
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
|
||||
import me.proton.core.drive.base.domain.util.coRunCatching
|
||||
import me.proton.core.drive.base.presentation.entity.toFileTypeCategory
|
||||
import me.proton.core.drive.base.presentation.extension.log
|
||||
import me.proton.core.drive.base.presentation.extension.require
|
||||
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
|
||||
import me.proton.core.drive.documentsprovider.domain.usecase.GetDocumentUri
|
||||
import me.proton.core.drive.drivelink.crypto.domain.usecase.GetDecryptedDriveLink
|
||||
import me.proton.core.drive.drivelink.domain.entity.DriveLink
|
||||
import me.proton.core.drive.drivelink.domain.extension.getThumbnailId
|
||||
import me.proton.core.drive.drivelink.domain.extension.isNameEncrypted
|
||||
import me.proton.core.drive.drivelink.domain.usecase.GetDriveLink
|
||||
import me.proton.core.drive.drivelink.domain.usecase.GetDriveLinksCount
|
||||
@@ -75,6 +82,7 @@ import me.proton.core.drive.drivelink.list.domain.usecase.GetDecryptedDriveLinks
|
||||
import me.proton.core.drive.drivelink.offline.domain.usecase.GetDecryptedOfflineDriveLinks
|
||||
import me.proton.core.drive.drivelink.offline.domain.usecase.GetOfflineDriveLinksCount
|
||||
import me.proton.core.drive.drivelink.sorting.domain.usecase.SortDriveLinks
|
||||
import me.proton.core.drive.file.base.domain.entity.ThumbnailType
|
||||
import me.proton.core.drive.files.preview.presentation.component.PreviewComposable
|
||||
import me.proton.core.drive.files.preview.presentation.component.event.PreviewViewEvent
|
||||
import me.proton.core.drive.files.preview.presentation.component.state.ContentState
|
||||
@@ -83,16 +91,23 @@ import me.proton.core.drive.files.preview.presentation.component.state.PreviewVi
|
||||
import me.proton.core.drive.files.preview.presentation.component.toComposable
|
||||
import me.proton.core.drive.link.domain.entity.FileId
|
||||
import me.proton.core.drive.link.domain.entity.FolderId
|
||||
import me.proton.core.drive.link.domain.entity.Link
|
||||
import me.proton.core.drive.link.domain.entity.LinkId
|
||||
import me.proton.core.drive.link.domain.extension.rootFolderId
|
||||
import me.proton.core.drive.photo.domain.entity.PhotoListing
|
||||
import me.proton.core.drive.photo.domain.repository.PhotoRepository
|
||||
import me.proton.core.drive.share.crypto.domain.usecase.GetPhotoShare
|
||||
import me.proton.core.drive.share.domain.entity.Share
|
||||
import me.proton.core.drive.share.domain.entity.ShareId
|
||||
import me.proton.core.drive.sorting.domain.usecase.GetSorting
|
||||
import me.proton.core.drive.thumbnail.presentation.extension.thumbnailVO
|
||||
import me.proton.core.util.kotlin.CoreLogger
|
||||
import javax.inject.Inject
|
||||
import me.proton.core.drive.i18n.R as I18N
|
||||
import me.proton.core.presentation.R as CorePresentation
|
||||
|
||||
@HiltViewModel
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
@Suppress("StaticFieldLeak", "LongParameterList")
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class PreviewViewModel @Inject constructor(
|
||||
@ApplicationContext private val appContext: Context,
|
||||
@@ -106,6 +121,8 @@ class PreviewViewModel @Inject constructor(
|
||||
getOfflineDriveLinksCount: GetOfflineDriveLinksCount,
|
||||
getDriveLinksCount: GetDriveLinksCount,
|
||||
getSorting: GetSorting,
|
||||
getPhotoShare: GetPhotoShare,
|
||||
photoRepository: PhotoRepository,
|
||||
sortDriveLinks: SortDriveLinks,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
|
||||
@@ -143,6 +160,14 @@ class PreviewViewModel @Inject constructor(
|
||||
getOfflineDriveLinksCount = getOfflineDriveLinksCount,
|
||||
coroutineScope = viewModelScope,
|
||||
)
|
||||
PagerType.PHOTO -> PhotoContentProvider(
|
||||
userId = userId,
|
||||
getDecryptedDriveLink = getDecryptedDriveLink,
|
||||
getPhotoShare = getPhotoShare,
|
||||
photoRepository = photoRepository,
|
||||
configurationProvider = configurationProvider,
|
||||
coroutineScope = viewModelScope,
|
||||
)
|
||||
}
|
||||
|
||||
private val contentStatesCache = mutableMapOf<FileId, Flow<ContentState>>()
|
||||
@@ -155,7 +180,7 @@ class PreviewViewModel @Inject constructor(
|
||||
|
||||
private val _previewEffect = MutableSharedFlow<PreviewEffect>()
|
||||
private val isFullscreen = MutableStateFlow(false)
|
||||
private val renderFailed = MutableStateFlow<Throwable?>(null)
|
||||
private val renderFailed = MutableStateFlow<Pair<Throwable, Any>?>(null)
|
||||
val initialViewState = PreviewViewState(
|
||||
navigationIconResId = CorePresentation.drawable.ic_arrow_back,
|
||||
isFullscreen = isFullscreen,
|
||||
@@ -166,7 +191,9 @@ class PreviewViewModel @Inject constructor(
|
||||
val viewState: Flow<PreviewViewState> = driveLinks.filterNotNull().transformLatest { driveLinks ->
|
||||
val indexOfFirst = driveLinks.indexOfFirst { link -> link.id == fileId }
|
||||
val contentStates =
|
||||
driveLinks.associateBy({ link -> link.id }) { link -> link.getContentStateFlow() }
|
||||
driveLinks
|
||||
.filter { driveLink -> driveLink.activeRevisionId.isNotEmpty() }
|
||||
.associateBy({ link -> link.id }) { link -> link.getContentStateFlow() }
|
||||
val (previewContentState, index) = when {
|
||||
driveLinks.isEmpty() || indexOfFirst == -1 -> PreviewContentState.Empty to 0
|
||||
else -> PreviewContentState.Content to indexOfFirst
|
||||
@@ -181,7 +208,7 @@ class PreviewViewModel @Inject constructor(
|
||||
title = link.name,
|
||||
isTitleEncrypted = link.isNameEncrypted,
|
||||
category = category,
|
||||
contentState = requireNotNull(contentStates[link.id])
|
||||
contentState = contentStates[link.id] ?: flowOf(ContentState.Decrypting),
|
||||
)
|
||||
},
|
||||
currentIndex = index,
|
||||
@@ -200,7 +227,7 @@ class PreviewViewModel @Inject constructor(
|
||||
override val onTopAppBarNavigation = { navigateBack() }
|
||||
override val onMoreOptions = { navigateToFileOrFolderOptions(fileId) }
|
||||
override val onSingleTap = { toggleFullscreen() }
|
||||
override val onRenderFailed = { throwable: Throwable -> renderFailed.value = throwable }
|
||||
override val onRenderFailed = { throwable: Throwable, source: Any -> renderFailed.value = throwable to source }
|
||||
override val mediaControllerVisibility = { visible: Boolean ->
|
||||
if ((visible && isFullscreen.value) || (!visible && !isFullscreen.value)) {
|
||||
toggleFullscreen()
|
||||
@@ -238,17 +265,23 @@ class PreviewViewModel @Inject constructor(
|
||||
|
||||
private fun getContentState(
|
||||
getFileState: GetFile.State,
|
||||
renderFailed: Throwable? = null,
|
||||
renderFailed: Pair<Throwable, Any>? = null,
|
||||
fallback: Map<Any, Any?> = emptyMap(),
|
||||
): ContentState {
|
||||
return renderFailed?.let { throwable ->
|
||||
ContentState.Error.NonRetryable(
|
||||
val uri = getUri(fileId)
|
||||
return renderFailed?.takeIf { (_, source) ->
|
||||
fallback[source] == source || source == uri
|
||||
}?.let { (throwable, source) ->
|
||||
fallback[source]?.let { fallbackSource ->
|
||||
getFileState.toContentState(this, fallbackSource)
|
||||
} ?: ContentState.Error.NonRetryable(
|
||||
message = throwable.getDefaultMessage(
|
||||
context = appContext,
|
||||
useExceptionMessage = configurationProvider.useExceptionMessage,
|
||||
),
|
||||
messageResId = 0,
|
||||
)
|
||||
} ?: getFileState.toContentState(this)
|
||||
} ?: getFileState.toContentState(this, uri)
|
||||
}
|
||||
|
||||
fun getUri(fileId: FileId) = getDocumentUri(userId, fileId)
|
||||
@@ -262,12 +295,30 @@ class PreviewViewModel @Inject constructor(
|
||||
getFile(this, trigger.verifySignature),
|
||||
renderFailed,
|
||||
) { fileState, renderFailed ->
|
||||
getContentState(fileState, renderFailed)
|
||||
getContentState(fileState, renderFailed, previewFallbackSources)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val DriveLink.File.previewFallbackSources: Map<Any, Any?> get() {
|
||||
val uri = getUri(id)
|
||||
val photoThumbnailVO = getThumbnailId(ThumbnailType.PHOTO)?.let { thumbnailVO(ThumbnailType.PHOTO) }
|
||||
val defaultThumbnailVO = getThumbnailId(ThumbnailType.DEFAULT)?.let { thumbnailVO(ThumbnailType.DEFAULT) }
|
||||
return when {
|
||||
photoThumbnailVO == null -> mapOf(uri to null)
|
||||
defaultThumbnailVO == null -> mapOf(
|
||||
uri to photoThumbnailVO,
|
||||
photoThumbnailVO to null,
|
||||
)
|
||||
else -> mapOf(
|
||||
uri to photoThumbnailVO,
|
||||
photoThumbnailVO to defaultThumbnailVO,
|
||||
defaultThumbnailVO to null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class Trigger(
|
||||
val fileId: FileId,
|
||||
val verifySignature: Boolean = true,
|
||||
@@ -279,11 +330,11 @@ class PreviewViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
|
||||
fun GetFile.State.toContentState(viewModel: PreviewViewModel): ContentState {
|
||||
fun GetFile.State.toContentState(viewModel: PreviewViewModel, source: Any): ContentState {
|
||||
return when (this) {
|
||||
is GetFile.State.Downloading -> ContentState.Downloading(progress)
|
||||
GetFile.State.Decrypting -> ContentState.Decrypting
|
||||
is GetFile.State.Ready -> ContentState.Available(viewModel.getUri(fileId))
|
||||
is GetFile.State.Ready -> ContentState.Available(source)
|
||||
GetFile.State.Error.NoConnection,
|
||||
is GetFile.State.Error.Downloading -> ContentState.Error.Retryable(
|
||||
messageResId = I18N.string.description_file_download_failed,
|
||||
@@ -449,3 +500,100 @@ class OfflineContentProvider(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class PhotoContentProvider(
|
||||
private val getDecryptedDriveLink: GetDecryptedDriveLink,
|
||||
getPhotoShare: GetPhotoShare,
|
||||
photoRepository: PhotoRepository,
|
||||
userId: UserId,
|
||||
configurationProvider: ConfigurationProvider,
|
||||
coroutineScope: CoroutineScope,
|
||||
) : PreviewContentProvider {
|
||||
|
||||
private val photoShare: StateFlow<Share?> = getPhotoShare(userId)
|
||||
.filterSuccessOrError()
|
||||
.mapSuccessValueOrNull()
|
||||
.stateIn(coroutineScope, SharingStarted.Eagerly, null)
|
||||
|
||||
private val photoListings: StateFlow<List<PhotoListing>> = photoShare
|
||||
.filterNotNull()
|
||||
.distinctUntilChanged()
|
||||
.transform { photoShare ->
|
||||
emitAll(
|
||||
photoRepository.getPhotoListingCount(userId, photoShare.volumeId)
|
||||
.distinctUntilChanged()
|
||||
.transformLatest {
|
||||
emit(
|
||||
pagedList(
|
||||
pageSize = configurationProvider.dbPageSize,
|
||||
) { fromIndex, count ->
|
||||
photoRepository.getPhotoListings(userId, photoShare.volumeId, fromIndex, count)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
.stateIn(coroutineScope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
override fun getDriveLinks(fileId: FileId): Flow<List<DriveLink.File>> = combine(
|
||||
photoShare.filterNotNull(),
|
||||
getDecryptedDriveLink(fileId).filterSuccessOrError().mapSuccessValueOrNull(),
|
||||
photoListings,
|
||||
) { photoShare, driveLink, photoListings ->
|
||||
photoListings.map { photoListing ->
|
||||
if (photoListing.linkId == driveLink?.id) {
|
||||
driveLink
|
||||
} else {
|
||||
photoListing.placeholderDriveLink(photoShare)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun PhotoListing.placeholderDriveLink(photoShare: Share): DriveLink.File =
|
||||
DriveLink.File(
|
||||
link = Link.File(
|
||||
id = linkId as FileId,
|
||||
parentId = photoShare.rootFolderId,
|
||||
name = "",
|
||||
size = 0.bytes,
|
||||
lastModified = TimestampS(),
|
||||
mimeType = "",
|
||||
isShared = false,
|
||||
key = "",
|
||||
passphrase = "",
|
||||
passphraseSignature = "",
|
||||
numberOfAccesses = 0,
|
||||
shareUrlExpirationTime = null,
|
||||
uploadedBy = "",
|
||||
isFavorite = false,
|
||||
attributes = Attributes(0),
|
||||
permissions = Permissions(),
|
||||
state = Link.State.ACTIVE,
|
||||
nameSignatureEmail = null,
|
||||
hash = nameHash.orEmpty(),
|
||||
expirationTime = null,
|
||||
nodeKey = "",
|
||||
nodePassphrase = "",
|
||||
nodePassphraseSignature = "",
|
||||
signatureAddress = "",
|
||||
creationTime = TimestampS(),
|
||||
trashedTime = null,
|
||||
hasThumbnail = false,
|
||||
activeRevisionId = "",
|
||||
xAttr = null,
|
||||
shareUrlId = null,
|
||||
contentKeyPacket = "",
|
||||
contentKeyPacketSignature = null,
|
||||
photoCaptureTime = captureTime,
|
||||
photoContentHash = contentHash,
|
||||
mainPhotoLinkId = mainPhotoLinkId,
|
||||
),
|
||||
volumeId = photoShare.volumeId,
|
||||
isMarkedAsOffline = false,
|
||||
isAnyAncestorMarkedAsOffline = false,
|
||||
downloadState = null,
|
||||
trashState = null,
|
||||
cryptoName = CryptoProperty.Encrypted(""),
|
||||
cryptoXAttr = CryptoProperty.Encrypted(""),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.ui.viewmodel
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import me.proton.core.drive.base.presentation.common.Action
|
||||
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
|
||||
import me.proton.core.drive.drivelink.domain.entity.DriveLink
|
||||
import me.proton.core.drive.drivelink.selection.domain.usecase.GetSelectedDriveLinks
|
||||
import me.proton.core.drive.drivelink.selection.domain.usecase.SelectAll
|
||||
import me.proton.core.drive.i18n.R
|
||||
import me.proton.core.drive.link.domain.entity.FolderId
|
||||
import me.proton.core.drive.link.domain.entity.LinkId
|
||||
import me.proton.core.drive.link.selection.domain.entity.SelectionId
|
||||
import me.proton.core.drive.link.selection.domain.usecase.DeselectLinks
|
||||
import me.proton.core.drive.link.selection.domain.usecase.SelectLinks
|
||||
import me.proton.core.presentation.R as CorePresentation
|
||||
import me.proton.core.drive.i18n.R as I18N
|
||||
|
||||
@Suppress("TooManyFunctions")
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
open class SelectionViewModel(
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
private val selectLinks: SelectLinks,
|
||||
private val deselectLinks: DeselectLinks,
|
||||
private val selectAll: SelectAll,
|
||||
private val getSelectedDriveLinks: GetSelectedDriveLinks,
|
||||
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
|
||||
protected val selectionId = MutableStateFlow(
|
||||
savedStateHandle.get<String?>(KEY_SELECTION_ID)?.let { SelectionId(it) }
|
||||
)
|
||||
protected val parentFolderId = MutableStateFlow<FolderId?>(null)
|
||||
protected val selected: StateFlow<Set<LinkId>> = selectionId
|
||||
.filterNotNull()
|
||||
.transformLatest { id ->
|
||||
val parentId = parentFolderId.filterNotNull().first()
|
||||
emitAll(
|
||||
getSelectedDriveLinks(id, parentId).map { driveLinks ->
|
||||
driveLinks.map { driveLink -> driveLink.id }.toSet()
|
||||
}
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptySet())
|
||||
protected val selectAllAction = Action(
|
||||
iconResId = CorePresentation.drawable.ic_proton_check_triple,
|
||||
contentDescriptionResId = I18N.string.content_description_select_all,
|
||||
onAction = {
|
||||
viewModelScope.launch {
|
||||
selectAll(parentFolderId.filterNotNull().first(), selectionId.value)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
selectionId.value?.let { selectionId ->
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
deselectLinks(selectionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected fun selectedOptionsAction(onAction: (() -> Unit)?) = Action(
|
||||
iconResId = CorePresentation.drawable.ic_proton_three_dots_vertical,
|
||||
contentDescriptionResId = R.string.content_description_selected_options,
|
||||
onAction = { onAction?.invoke() }
|
||||
)
|
||||
|
||||
protected fun onTopAppBarNavigation(nonSelectedBlock: () -> Unit): () -> Unit = {
|
||||
Unit.also {
|
||||
if (selected.value.isNotEmpty()) {
|
||||
selectionId.value?.let { viewModelScope.launch { deselectLinks(it) } }
|
||||
} else {
|
||||
nonSelectedBlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected fun onDriveLink(driveLink: DriveLink, nonSelectedBlock: () -> Unit) {
|
||||
if (selected.value.isNotEmpty()) {
|
||||
if (selected.value.contains(driveLink.id)) {
|
||||
removeSelected(listOf(driveLink.id))
|
||||
} else {
|
||||
addSelected(listOf(driveLink.id))
|
||||
}
|
||||
} else {
|
||||
nonSelectedBlock()
|
||||
}
|
||||
}
|
||||
|
||||
protected inline fun <reified T : LinkId> onSelectedOptions(
|
||||
navigateToFileOrFolderOptions: (linkId: T) -> Unit,
|
||||
navigateToMultipleFileOrFolderOptions: (selectionId: SelectionId) -> Unit,
|
||||
) {
|
||||
if (selected.value.size == 1) {
|
||||
navigateToFileOrFolderOptions(selected.value.first() as T)
|
||||
} else {
|
||||
selectionId.value?.let { selectionId -> navigateToMultipleFileOrFolderOptions(selectionId) }
|
||||
}
|
||||
}
|
||||
|
||||
protected fun onSelectDriveLink(driveLink: DriveLink) = addSelected(listOf(driveLink.id))
|
||||
|
||||
protected fun onDeselectDriveLink(driveLink: DriveLink) = removeSelected(listOf(driveLink.id))
|
||||
|
||||
protected fun onBack() { removeAllSelected() }
|
||||
|
||||
private fun addSelected(linkIds: List<LinkId>) {
|
||||
viewModelScope.launch {
|
||||
selectionId.value?.let { selectionId ->
|
||||
selectLinks(selectionId, linkIds)
|
||||
} ?: setSelectionId(selectLinks(linkIds).getOrNull())
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeSelected(linkIds: List<LinkId>) {
|
||||
viewModelScope.launch {
|
||||
selectionId.value?.let { selectionId ->
|
||||
deselectLinks(selectionId, linkIds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeAllSelected() {
|
||||
if (selected.value.isNotEmpty()) {
|
||||
viewModelScope.launch {
|
||||
selectionId.value?.let { selectionId -> deselectLinks(selectionId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setSelectionId(selectionId: SelectionId?) {
|
||||
this.selectionId.value = selectionId
|
||||
savedStateHandle[KEY_SELECTION_ID] = selectionId?.id
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_SELECTION_ID = "key.selectionId"
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,10 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import me.proton.android.drive.BuildConfig
|
||||
import me.proton.android.drive.extension.getDefaultMessage
|
||||
@@ -41,10 +44,13 @@ import me.proton.android.drive.lock.domain.usecase.GetAutoLockDuration
|
||||
import me.proton.android.drive.lock.domain.usecase.HasEnableAppLockTimestamp
|
||||
import me.proton.android.drive.settings.DebugSettings
|
||||
import me.proton.android.drive.usecase.SendDebugLog
|
||||
import me.proton.core.drive.backup.domain.manager.BackupManager
|
||||
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
|
||||
import me.proton.core.drive.base.domain.usecase.BroadcastMessages
|
||||
import me.proton.core.drive.base.domain.usecase.ClearCacheFolder
|
||||
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
|
||||
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId
|
||||
import me.proton.core.drive.feature.flag.domain.usecase.IsFeatureFlagEnabled
|
||||
import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
|
||||
import me.proton.core.drive.settings.presentation.component.DebugSettingsStateAndEvent
|
||||
import me.proton.core.drive.settings.presentation.event.DebugSettingsViewEvent
|
||||
@@ -56,6 +62,7 @@ import me.proton.drive.android.settings.domain.entity.ThemeStyle
|
||||
import me.proton.drive.android.settings.domain.usecase.GetThemeStyle
|
||||
import me.proton.drive.android.settings.domain.usecase.UpdateThemeStyle
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import me.proton.core.drive.base.domain.extension.combine as baseCombine
|
||||
import me.proton.core.drive.i18n.R as I18N
|
||||
import me.proton.core.presentation.R as CorePresentation
|
||||
@@ -75,21 +82,93 @@ class SettingsViewModel @Inject constructor(
|
||||
private val broadcastMessages: BroadcastMessages,
|
||||
private val configurationProvider: ConfigurationProvider,
|
||||
private val sendDebugLog: SendDebugLog,
|
||||
backupManager: BackupManager,
|
||||
private val isFeatureFlagEnabled: IsFeatureFlagEnabled,
|
||||
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
|
||||
|
||||
private val _errorMessage = MutableSharedFlow<String>()
|
||||
val errorMessage: SharedFlow<String> = _errorMessage
|
||||
|
||||
val viewState: Flow<SettingsViewState> = baseCombine(
|
||||
debugSettings.baseUrlFlow,
|
||||
private val debugSettingsViewEvent = object : DebugSettingsViewEvent {
|
||||
override val onUpdateHost = { host: String -> debugSettings.host = host }
|
||||
override val onUpdateBaseUrl = { baseUrl: String -> debugSettings.baseUrl = baseUrl }
|
||||
override val onUpdateAppVersionHeader = { header: String -> debugSettings.appVersionHeader = header }
|
||||
override val onToggleUseExceptionMessage = { useExceptionMessage: Boolean ->
|
||||
debugSettings.useExceptionMessage = useExceptionMessage
|
||||
}
|
||||
override val onToggleLogToFileEnabled = { logToFileEnabled: Boolean ->
|
||||
debugSettings.logToFileInDebugEnabled = logToFileEnabled
|
||||
}
|
||||
override val onToggleAllowBackupDeletedFile = { logToFileEnabled: Boolean ->
|
||||
debugSettings.allowBackupDeletedFilesEnabled = logToFileEnabled
|
||||
}
|
||||
override val sendDebugLog = { context: Context ->
|
||||
viewModelScope.launch {
|
||||
sendDebugLog(context)
|
||||
.onFailure { error ->
|
||||
broadcastMessages(
|
||||
userId = userId,
|
||||
message = error.getDefaultMessage(
|
||||
context = context,
|
||||
useExceptionMessage = true,
|
||||
),
|
||||
type = BroadcastMessage.Type.ERROR,
|
||||
)
|
||||
}
|
||||
}
|
||||
Unit
|
||||
}
|
||||
override val onReset = { debugSettings.reset(viewModelScope) }
|
||||
override val onUpdateFeatureFlagFreshDuration = { featureFlagFreshDuration: String ->
|
||||
debugSettings.featureFlagFreshDuration = (featureFlagFreshDuration.toLong()).minutes
|
||||
}
|
||||
override val onToggleUseVerifier = { useVerifier: Boolean ->
|
||||
debugSettings.useVerifier = useVerifier
|
||||
}
|
||||
}
|
||||
|
||||
private val debugSettingsFlow = baseCombine(debugSettings.baseUrlFlow,
|
||||
debugSettings.hostFlow,
|
||||
debugSettings.appVersionHeaderFlow,
|
||||
debugSettings.useExceptionMessageFlow,
|
||||
debugSettings.logToFileEnabledFlow,
|
||||
debugSettings.allowBackupDeletedFilesEnabledFlow,
|
||||
debugSettings.featureFlagFreshDurationFlow,
|
||||
debugSettings.useVerifierFlow,
|
||||
) {
|
||||
baseUrl,
|
||||
host,
|
||||
appVersionHeader,
|
||||
useExceptionMessage,
|
||||
logToFileEnabled,
|
||||
allowBackupDeletedFilesEnabled,
|
||||
featureFlagFreshDuration,
|
||||
useVerifier
|
||||
->
|
||||
getDebugSettings(
|
||||
host = host,
|
||||
baseUrl = baseUrl,
|
||||
appVersionHeader = appVersionHeader,
|
||||
useExceptionMessage = useExceptionMessage,
|
||||
logToFileEnabled = logToFileEnabled,
|
||||
allowBackupDeletedFilesEnabled = allowBackupDeletedFilesEnabled,
|
||||
featureFlagFreshDuration = featureFlagFreshDuration.toString(),
|
||||
useVerifier = useVerifier,
|
||||
)
|
||||
}
|
||||
|
||||
private val isPhotosFeatureEnabled: StateFlow<Boolean> = flow {
|
||||
emit(configurationProvider.photosFeatureFlag && isFeatureFlagEnabled(FeatureFlagId.drivePhotos(userId)))
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, configurationProvider.photosFeatureFlag)
|
||||
|
||||
val viewState: Flow<SettingsViewState> = baseCombine(
|
||||
debugSettingsFlow,
|
||||
getThemeStyle(userId),
|
||||
appLockManager.enabled,
|
||||
getAutoLockDuration(),
|
||||
) { baseUrl, host, appVersionHeader, useExceptionMessage, logToFileEnabled, themeStyle, enabled, autoLockDuration ->
|
||||
backupManager.isEnabled(userId),
|
||||
isPhotosFeatureEnabled,
|
||||
) { debugSettings, themeStyle, enabled, autoLockDuration, isBackupEnabled, isPhotosFeatureEnabled ->
|
||||
SettingsViewState(
|
||||
navigationIcon = CorePresentation.drawable.ic_arrow_back,
|
||||
appNameResId = I18N.string.app_name,
|
||||
@@ -106,16 +185,12 @@ class SettingsViewModel @Inject constructor(
|
||||
),
|
||||
availableStyles = enumValues<ThemeStyle>().map { style -> style.resId },
|
||||
currentStyle = themeStyle.resId,
|
||||
debugSettingsStateAndEvent = getDebugSettings(
|
||||
host = host,
|
||||
baseUrl = baseUrl,
|
||||
appVersionHeader = appVersionHeader,
|
||||
useExceptionMessage = useExceptionMessage,
|
||||
logToFileEnabled = logToFileEnabled,
|
||||
),
|
||||
debugSettingsStateAndEvent = debugSettings,
|
||||
appAccessSubtitleResId = getAppAccessSubtitleResId(enabled),
|
||||
isAutoLockDurationsVisible = enabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O,
|
||||
autoLockDuration = autoLockDuration,
|
||||
isPhotosSettingsVisible = isPhotosFeatureEnabled,
|
||||
photosBackupSubtitleResId = getPhotosBackupSubtitleResId(isBackupEnabled),
|
||||
)
|
||||
}.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
|
||||
|
||||
@@ -123,6 +198,7 @@ class SettingsViewModel @Inject constructor(
|
||||
navigateBack: () -> Unit,
|
||||
navigateToAppAccess: () -> Unit,
|
||||
navigateToAutoLockDurations: () -> Unit,
|
||||
navigateToPhotosBackup: () -> Unit,
|
||||
) = SettingsViewEvent(
|
||||
navigateBack = navigateBack,
|
||||
onLinkClicked = { link ->
|
||||
@@ -159,7 +235,10 @@ class SettingsViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onPhotosBackup = {
|
||||
navigateToPhotosBackup()
|
||||
},
|
||||
)
|
||||
|
||||
private suspend fun getAppAccessSubtitleResId(isAppLockEnabled: Boolean): Int = when {
|
||||
@@ -168,6 +247,13 @@ class SettingsViewModel @Inject constructor(
|
||||
else -> I18N.string.app_lock_option_none
|
||||
}
|
||||
|
||||
private fun getPhotosBackupSubtitleResId(isBackupEnabled: Boolean): Int =
|
||||
if (isBackupEnabled) {
|
||||
I18N.string.common_on
|
||||
} else {
|
||||
I18N.string.common_off
|
||||
}
|
||||
|
||||
private fun onExternalLinkClicked(link: LegalLink.External) {
|
||||
try {
|
||||
context.startActivity(
|
||||
@@ -187,47 +273,28 @@ class SettingsViewModel @Inject constructor(
|
||||
appVersionHeader: String,
|
||||
useExceptionMessage: Boolean,
|
||||
logToFileEnabled: Boolean,
|
||||
allowBackupDeletedFilesEnabled: Boolean,
|
||||
featureFlagFreshDuration: String,
|
||||
useVerifier: Boolean,
|
||||
): DebugSettingsStateAndEvent? =
|
||||
BuildConfig.DEBUG
|
||||
(BuildConfig.DEBUG || BuildConfig.FLAVOR == BuildConfig.FLAVOR_ALPHA)
|
||||
.takeIf { isDebug -> isDebug }
|
||||
?.let {
|
||||
DebugSettingsStateAndEvent(
|
||||
viewState = DebugSettingsViewState(
|
||||
host, baseUrl, appVersionHeader, useExceptionMessage, logToFileEnabled,
|
||||
host = host,
|
||||
baseUrl = baseUrl,
|
||||
appVersionHeader = appVersionHeader,
|
||||
useExceptionMessage = useExceptionMessage,
|
||||
logToFileEnabled = logToFileEnabled,
|
||||
allowBackupDeletedFiles = allowBackupDeletedFilesEnabled,
|
||||
featureFlagFreshDuration = featureFlagFreshDuration,
|
||||
useVerifier = useVerifier,
|
||||
),
|
||||
viewEvent = debugSettingsViewEvent
|
||||
)
|
||||
}
|
||||
|
||||
private val debugSettingsViewEvent = object : DebugSettingsViewEvent {
|
||||
override val onUpdateHost = { host: String -> debugSettings.host = host }
|
||||
override val onUpdateBaseUrl = { baseUrl: String -> debugSettings.baseUrl = baseUrl }
|
||||
override val onUpdateAppVersionHeader = { header: String -> debugSettings.appVersionHeader = header }
|
||||
override val onToggleUseExceptionMessage = { useExceptionMessage: Boolean ->
|
||||
debugSettings.useExceptionMessage = useExceptionMessage
|
||||
}
|
||||
override val onToggleLogToFileEnabled = { logToFileEnabled: Boolean ->
|
||||
debugSettings.logToFileInDebugEnabled = logToFileEnabled
|
||||
}
|
||||
override val sendDebugLog = { context: Context ->
|
||||
viewModelScope.launch {
|
||||
sendDebugLog(context)
|
||||
.onFailure { error ->
|
||||
broadcastMessages(
|
||||
userId = userId,
|
||||
message = error.getDefaultMessage(
|
||||
context = context,
|
||||
useExceptionMessage = true,
|
||||
),
|
||||
type = BroadcastMessage.Type.ERROR,
|
||||
)
|
||||
}
|
||||
}
|
||||
Unit
|
||||
}
|
||||
override val onReset = { debugSettings.reset(viewModelScope) }
|
||||
}
|
||||
|
||||
private val ThemeStyle.resId: Int get() = when (this) {
|
||||
ThemeStyle.SYSTEM -> I18N.string.settings_theme_system_default
|
||||
ThemeStyle.DARK -> I18N.string.settings_theme_dark
|
||||
|
||||
@@ -51,12 +51,12 @@ import me.proton.core.domain.arch.DataResult
|
||||
import me.proton.core.domain.arch.ResponseSource
|
||||
import me.proton.core.domain.arch.mapSuccessValueOrNull
|
||||
import me.proton.core.domain.arch.onSuccess
|
||||
import me.proton.core.drive.base.data.extension.log
|
||||
import me.proton.core.drive.base.data.extension.logDefaultMessage
|
||||
import me.proton.core.drive.base.domain.entity.Percentage
|
||||
import me.proton.core.drive.base.domain.extension.onFailure
|
||||
import me.proton.core.drive.base.domain.log.LogTag.VIEW_MODEL
|
||||
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
|
||||
import me.proton.core.drive.base.presentation.extension.log
|
||||
import me.proton.core.drive.base.presentation.extension.logDefaultMessage
|
||||
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
|
||||
import me.proton.core.drive.drivelink.crypto.domain.usecase.GetDecryptedDriveLink
|
||||
import me.proton.core.drive.drivelink.domain.entity.DriveLink
|
||||
@@ -71,6 +71,7 @@ import me.proton.core.drive.link.domain.entity.LinkId
|
||||
import me.proton.core.drive.link.domain.extension.isSharedUrlExpired
|
||||
import me.proton.core.drive.share.domain.entity.ShareId
|
||||
import me.proton.core.drive.share.domain.usecase.GetMainShare
|
||||
import me.proton.core.drive.share.domain.usecase.GetShare
|
||||
import me.proton.core.drive.sorting.domain.entity.Sorting
|
||||
import me.proton.core.drive.sorting.domain.usecase.GetSorting
|
||||
import me.proton.drive.android.settings.domain.entity.LayoutType
|
||||
@@ -95,24 +96,31 @@ class SharedViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
@ApplicationContext private val appContext: Context,
|
||||
private val configurationProvider: ConfigurationProvider,
|
||||
private val getShare: GetShare,
|
||||
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle), HomeTabViewModel {
|
||||
private val _effects = MutableSharedFlow<HomeEffect>()
|
||||
private val refreshTrigger = MutableSharedFlow<Unit>(replay = 1).apply { tryEmit(Unit) }
|
||||
private val shareId = refreshTrigger.transformLatest {
|
||||
private val volumeId = refreshTrigger.transformLatest {
|
||||
savedStateHandle.get<String>(Screen.Shared.SHARE_ID)?.takeIf { shareId -> shareId != "null" }?.let { shareId ->
|
||||
emit(ShareId(userId, shareId))
|
||||
emitAll(
|
||||
getShare(ShareId(userId, shareId))
|
||||
.mapSuccessValueOrNull()
|
||||
.filterNotNull()
|
||||
.map { share -> share.volumeId }
|
||||
.distinctUntilChanged()
|
||||
)
|
||||
} ?: emitAll(
|
||||
getMainShare(userId)
|
||||
.map { dataResult -> dataResult.onFailure { error -> error.log(VIEW_MODEL) }}
|
||||
.mapSuccessValueOrNull()
|
||||
.filterNotNull()
|
||||
.map { share -> share.id }
|
||||
.map { share -> share.volumeId }
|
||||
.distinctUntilChanged()
|
||||
)
|
||||
}
|
||||
|
||||
private val driveLinks: Flow<DataResult<List<DriveLink>>> = shareId.flatMapLatest { shareId ->
|
||||
getSharedDriveLinks(shareId, refresh = true)
|
||||
private val driveLinks: Flow<DataResult<List<DriveLink>>> = volumeId.flatMapLatest { volumeId ->
|
||||
getSharedDriveLinks(userId, volumeId, refresh = true)
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, initialValue = DataResult.Processing(ResponseSource.Local))
|
||||
|
||||
val driveLinksFlow = driveLinks.mapSuccessValueOrNull()
|
||||
|
||||
@@ -46,8 +46,10 @@ import me.proton.core.drive.drivelink.crypto.domain.usecase.GetDecryptedDriveLin
|
||||
import me.proton.core.drive.drivelink.domain.entity.DriveLink
|
||||
import me.proton.core.drive.drivelink.domain.extension.isNameEncrypted
|
||||
import me.proton.core.drive.drivelink.list.domain.usecase.GetPagedDriveLinksList
|
||||
import me.proton.core.drive.drivelink.upload.domain.entity.Notifications
|
||||
import me.proton.core.drive.drivelink.upload.domain.usecase.UploadFiles
|
||||
import me.proton.core.drive.link.domain.entity.FolderId
|
||||
import me.proton.core.drive.linkupload.domain.entity.UploadFileLink
|
||||
import me.proton.core.drive.share.domain.entity.ShareId
|
||||
import me.proton.core.drive.sorting.domain.entity.Sorting
|
||||
import me.proton.core.drive.upload.domain.exception.NotEnoughSpaceException
|
||||
@@ -160,7 +162,8 @@ class UploadToViewModel @Inject constructor(
|
||||
folder = folder,
|
||||
uriStrings = localUris,
|
||||
shouldDeleteSource = true,
|
||||
silently = true,
|
||||
notifications = Notifications.TurnedOnExceptPreparingUpload,
|
||||
priority = UploadFileLink.USER_PRIORITY,
|
||||
).getOrThrow()
|
||||
delay(2.seconds)
|
||||
exitApp()
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.ui.viewstate
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
data class BackupIssuesViewState(
|
||||
val medias: List<Uri>,
|
||||
)
|
||||
@@ -20,9 +20,10 @@ package me.proton.android.drive.ui.viewstate
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import me.proton.core.drive.drivelink.domain.entity.DriveLink
|
||||
import me.proton.core.drive.file.info.presentation.entity.Item
|
||||
|
||||
@Immutable
|
||||
data class FileInfoViewState(
|
||||
val link: DriveLink,
|
||||
val path: String,
|
||||
val items: List<Item>,
|
||||
)
|
||||
|
||||
@@ -26,8 +26,8 @@ import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import me.proton.core.compose.component.bottomsheet.ModalBottomSheetContentState
|
||||
import me.proton.core.compose.component.bottomsheet.rememberModalBottomSheetContentState
|
||||
import me.proton.core.drive.base.presentation.component.ModalBottomSheetContentState
|
||||
import me.proton.core.drive.base.presentation.component.rememberModalBottomSheetContentState
|
||||
|
||||
@Stable
|
||||
data class HomeScaffoldState(
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.ui.viewstate
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
data class PhotosBackupViewState(
|
||||
val title: String,
|
||||
val enableBackupTitle: String,
|
||||
val isBackupEnabled: Boolean,
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.ui.viewstate
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
data class PhotosExportDataViewState(
|
||||
val isExportDataEnabled: Boolean,
|
||||
val isExportDataLoading: Boolean,
|
||||
)
|
||||
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Drive is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.usecase
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.first
|
||||
import me.proton.core.domain.entity.UserId
|
||||
import me.proton.core.drive.backup.domain.usecase.GetFolders
|
||||
import me.proton.core.drive.base.domain.entity.TimestampS
|
||||
import me.proton.core.drive.base.domain.entity.toTimestampMs
|
||||
import me.proton.core.drive.base.domain.extension.toResult
|
||||
import me.proton.core.drive.base.domain.formatter.DateTimeFormatter
|
||||
import me.proton.core.drive.base.domain.function.pagedList
|
||||
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
|
||||
import me.proton.core.drive.base.domain.provider.StorageLocationProvider
|
||||
import me.proton.core.drive.base.domain.util.coRunCatching
|
||||
import me.proton.core.drive.drivelink.domain.entity.DriveLink
|
||||
import me.proton.core.drive.drivelink.list.domain.usecase.GetDecryptedDriveLinks
|
||||
import me.proton.core.drive.file.base.domain.extension.toXAttr
|
||||
import me.proton.core.drive.link.domain.entity.FolderId
|
||||
import me.proton.core.drive.link.domain.extension.rootFolderId
|
||||
import me.proton.core.drive.link.domain.extension.shareId
|
||||
import me.proton.core.drive.link.domain.extension.userId
|
||||
import me.proton.core.drive.share.crypto.domain.usecase.GetPhotoShare
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.Date
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
import javax.inject.Inject
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
class ExportPhotoData @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val configurationProvider: ConfigurationProvider,
|
||||
private val storageLocationProvider: StorageLocationProvider,
|
||||
private val dateTimeFormatter: DateTimeFormatter,
|
||||
private val getFolders: GetFolders,
|
||||
private val getPhotoShare: GetPhotoShare,
|
||||
private val getDecryptedDriveLinks: GetDecryptedDriveLinks,
|
||||
) {
|
||||
suspend operator fun invoke(
|
||||
userId: UserId,
|
||||
): Result<File> = coRunCatching {
|
||||
File(
|
||||
storageLocationProvider.getCacheTempFolder(userId),
|
||||
"exports/exports-${dateTimeFormatter.formatToIso8601String(Date())}.zip"
|
||||
).apply {
|
||||
parentFile?.mkdirs()
|
||||
ZipOutputStream(FileOutputStream(this)).use { zos ->
|
||||
zos.putNextEntry(ZipEntry("local.csv"))
|
||||
zos.exportLocalData(userId)
|
||||
zos.closeEntry()
|
||||
zos.putNextEntry(ZipEntry("drive.csv"))
|
||||
zos.exportDriveData(getPhotoShare(userId).toResult().getOrThrow().rootFolderId)
|
||||
zos.closeEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun OutputStream.exportLocalData(userId: UserId) {
|
||||
writeLine("name", "size", "mimetype", "modificationTime", "bucketId", "bucketName", "_id")
|
||||
getFolders(userId).getOrThrow().map { it.bucketId }.onEach { folderBucketId ->
|
||||
context.contentResolver.query(
|
||||
getFilesContentUri(),
|
||||
PROJECTION_BUCKET,
|
||||
"${MediaStore.Files.FileColumns.BUCKET_ID} LIKE ?",
|
||||
arrayOf(folderBucketId.toString()),
|
||||
"${MediaStore.Files.FileColumns.DATE_MODIFIED} DESC"
|
||||
)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val name = cursor.getString(INDEX_DISPLAY_NAME)
|
||||
val size = cursor.getInt(INDEX_SIZE).toLong()
|
||||
val mediaType = cursor.getInt(INDEX_MEDIA_TYPE)
|
||||
val mimeType = cursor.getString(INDEX_MIME_TYPE)
|
||||
val dateModified = cursor.getLong(INDEX_DATE_MODIFIED)
|
||||
val bucketId = cursor.getLong(INDEX_BUCKET_ID)
|
||||
val bucketName = cursor.getString(INDEX_BUCKET_DISPLAY_NAME)
|
||||
val id = cursor.getLong(INDEX_ID)
|
||||
val modificationDate = dateTimeFormatter.formatToIso8601String(
|
||||
Date(TimestampS(dateModified).toTimestampMs().value)
|
||||
)
|
||||
if (mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE
|
||||
|| mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO
|
||||
) {
|
||||
writeLine(
|
||||
name,
|
||||
size,
|
||||
mimeType,
|
||||
modificationDate,
|
||||
bucketId,
|
||||
bucketName,
|
||||
id
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun OutputStream.exportDriveData(rootFolderId: FolderId) {
|
||||
writeLine("name", "size", "mimetype", "modificationTime", "userId", "shareId", "parentId", "linkId")
|
||||
pagedList(configurationProvider.dbPageSize) { fromIndex, count ->
|
||||
getDecryptedDriveLinks(rootFolderId, fromIndex, count).first().getOrThrow()
|
||||
}.filterIsInstance(DriveLink.File::class.java).filterNot { driveLink ->
|
||||
driveLink.isTrashed
|
||||
}.onEach { driveLink ->
|
||||
val xAttrCommon = driveLink.cryptoXAttr.value?.toXAttr()?.getOrNull()?.common
|
||||
with(driveLink) {
|
||||
writeLine(
|
||||
name,
|
||||
xAttrCommon?.size,
|
||||
mimeType,
|
||||
xAttrCommon?.modificationTime,
|
||||
userId.id,
|
||||
shareId.id,
|
||||
parentId.id,
|
||||
id.id,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun OutputStream.writeLine(
|
||||
vararg data: Any?,
|
||||
) {
|
||||
val line = data.joinToString(";")
|
||||
write("$line\n".toByteArray())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
private val PROJECTION_BUCKET = arrayOf(
|
||||
MediaStore.Files.FileColumns.DISPLAY_NAME,
|
||||
MediaStore.Files.FileColumns.SIZE,
|
||||
MediaStore.Files.FileColumns.MEDIA_TYPE,
|
||||
MediaStore.Files.FileColumns.MIME_TYPE,
|
||||
MediaStore.Files.FileColumns.DATE_MODIFIED,
|
||||
MediaStore.Files.FileColumns.BUCKET_ID,
|
||||
MediaStore.Files.FileColumns.BUCKET_DISPLAY_NAME,
|
||||
MediaStore.Files.FileColumns._ID,
|
||||
)
|
||||
|
||||
// The indices should match the above projections.
|
||||
private const val INDEX_DISPLAY_NAME = 0
|
||||
private const val INDEX_SIZE = 1
|
||||
private const val INDEX_MEDIA_TYPE = 2
|
||||
private const val INDEX_MIME_TYPE = 3
|
||||
private const val INDEX_DATE_MODIFIED = 4
|
||||
private const val INDEX_BUCKET_ID = 5
|
||||
private const val INDEX_BUCKET_DISPLAY_NAME = 6
|
||||
private const val INDEX_ID = 7
|
||||
|
||||
private const val EXTERNAL_MEDIA = "external"
|
||||
private fun getFilesContentUri(): Uri {
|
||||
return MediaStore.Files.getContentUri(EXTERNAL_MEDIA)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,5 +25,5 @@ import javax.inject.Inject
|
||||
class GetDebugLogFile @Inject constructor(
|
||||
private val storageLocationProvider: StorageLocationProvider,
|
||||
) {
|
||||
operator fun invoke(): File = File(storageLocationProvider.getDebugLogFolder(), "debug.log")
|
||||
operator fun invoke(): File = File(storageLocationProvider.getDebugLogFolder(), "debug_%g.log")
|
||||
}
|
||||
|
||||
@@ -21,10 +21,10 @@ package me.proton.android.drive.usecase
|
||||
import android.database.sqlite.SQLiteDiskIOException
|
||||
import android.database.sqlite.SQLiteFullException
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import me.proton.core.drive.announce.event.domain.entity.Event
|
||||
import me.proton.core.drive.announce.event.domain.usecase.AnnounceEvent
|
||||
import me.proton.core.drive.base.domain.usecase.ClearCacheFolder
|
||||
import me.proton.core.drive.base.domain.util.coRunCatching
|
||||
import me.proton.core.drive.notification.domain.entity.NotificationEvent
|
||||
import me.proton.core.drive.notification.domain.usecase.AnnounceEvent
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -37,7 +37,7 @@ class HandleUncaughtException @Inject constructor(
|
||||
operator fun invoke(error: Throwable): Result<Boolean> = coRunCatching {
|
||||
val isNoSpaceLeftOnDevice = getInternalStorageInfo().getOrThrow().available.value == 0L
|
||||
if (isNoSpaceLeftOnDevice) runBlocking {
|
||||
announceEvent(NotificationEvent.NoSpaceLeftOnDevice)
|
||||
announceEvent(Event.NoSpaceLeftOnDevice)
|
||||
clearCacheFolder()
|
||||
}
|
||||
when (error) {
|
||||
|
||||
@@ -30,6 +30,7 @@ import me.proton.core.drive.base.domain.usecase.BroadcastMessages
|
||||
import me.proton.core.drive.drivelink.domain.entity.DriveLink
|
||||
import me.proton.core.drive.files.presentation.state.ListContentState
|
||||
import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
|
||||
import me.proton.core.drive.share.domain.entity.Share
|
||||
import me.proton.core.drive.share.domain.exception.ShareException
|
||||
import javax.inject.Inject
|
||||
import me.proton.core.drive.i18n.R as I18N
|
||||
@@ -44,15 +45,20 @@ class OnFilesDriveLinkError @Inject constructor(
|
||||
previous: DataResult<DriveLink.Folder>?,
|
||||
error: DataResult.Error,
|
||||
contentState: MutableStateFlow<ListContentState>,
|
||||
shareType: Share.Type = Share.Type.MAIN,
|
||||
) {
|
||||
when {
|
||||
error.isTransient(previous) -> error.broadcastMessage(userId)
|
||||
error.isTransient(previous, shareType) -> error.broadcastMessage(userId)
|
||||
else -> contentState.emit(error.toListContentState())
|
||||
}
|
||||
}
|
||||
|
||||
private fun DataResult.Error.isTransient(previous: DataResult<DriveLink.Folder>?): Boolean =
|
||||
(previous != null && previous !is DataResult.Error) || cause is ShareException.MainShareLocked
|
||||
private fun DataResult.Error.isTransient(
|
||||
previous: DataResult<DriveLink.Folder>?,
|
||||
shareType: Share.Type,
|
||||
): Boolean =
|
||||
(previous != null && previous !is DataResult.Error) ||
|
||||
((cause as? ShareException.ShareLocked)?.let { e -> e.shareType == shareType } ?: false)
|
||||
|
||||
private fun DataResult.Error.broadcastMessage(userId: UserId) = broadcastMessage?.let { message ->
|
||||
broadcastMessages(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user