This commit is contained in:
Damir Mihaljinec
2023-12-13 11:55:49 +01:00
parent cfbfde14d2
commit ef9ccf7a93
1228 changed files with 112303 additions and 2821 deletions
+145 -41
View File
@@ -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:
+4
View File
@@ -20,4 +20,8 @@ plugins {
id("com.android.library")
}
android {
namespace = "me.proton.android.drive.lock"
}
driveModule(includeSubmodules = true)
+5
View File
@@ -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)
}
+4
View File
@@ -20,6 +20,10 @@ plugins {
id("com.android.library")
}
android {
namespace = "me.proton.android.drive.lock.domain"
}
driveModule(
hilt = true,
serialization = true,
+5
View File
@@ -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)
}
@@ -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
+4
View File
@@ -20,6 +20,10 @@ plugins {
id("com.android.library")
}
android {
namespace = "me.proton.android.drive.settings"
}
driveModule(
hilt = true,
room = true,
+26 -6
View File
@@ -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(
+21 -10
View File
@@ -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,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)
}
}
}
}
@@ -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'")
@@ -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,
)
}
}
}
@@ -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)
}
@@ -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()
@@ -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() = {}
}
@@ -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,
)
}
}
}
}
@@ -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
}
@@ -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,
)
}
}
}
}
@@ -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 ->
@@ -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(),
@@ -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
@@ -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